mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-13 01:31:48 +08:00
Compare commits
2 Commits
docs/trust
...
codex/aa-0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be889937bc | ||
|
|
d00d6876f5 |
@@ -1,18 +1,22 @@
|
||||
# PR Review Instructions
|
||||
# PR Workflow for Maintainers
|
||||
|
||||
Please read this in full and do not skip sections.
|
||||
This is the single source of truth for the maintainer PR workflow.
|
||||
|
||||
## Triage order
|
||||
|
||||
Process PRs **oldest to newest**. Older PRs are more likely to have merge conflicts and stale dependencies; resolving them first keeps the queue healthy and avoids snowballing rebase pain.
|
||||
|
||||
## Working rule
|
||||
|
||||
Skills execute workflow, maintainers provide judgment.
|
||||
Skills execute workflow. Maintainers provide judgment.
|
||||
Always pause between skills to evaluate technical direction, not just command success.
|
||||
Default mode is local-first, do not write to GitHub until maintainer explicitly says go.
|
||||
|
||||
These three skills must be used in order:
|
||||
|
||||
1. `review-pr`
|
||||
2. `prepare-pr`
|
||||
3. `merge-pr`
|
||||
1. `review-pr` — review only, produce findings
|
||||
2. `prepare-pr` — rebase, fix, gate, push to PR head branch
|
||||
3. `merge-pr` — squash-merge, verify MERGED state, clean up
|
||||
|
||||
They are necessary, but not sufficient. Maintainers must steer between steps and understand the code before moving forward.
|
||||
|
||||
@@ -21,26 +25,64 @@ If submitted code is low quality, ignore it and implement the best solution for
|
||||
|
||||
Do not continue if you cannot verify the problem is real or test the fix.
|
||||
|
||||
## Remote write policy
|
||||
## Script-first contract
|
||||
|
||||
Until the maintainer explicitly approves remote actions, stay local-only.
|
||||
Skill runs should invoke these wrappers automatically. You only need to run them manually when debugging or doing an explicit script-only run:
|
||||
|
||||
Remote actions include:
|
||||
- `scripts/pr-review <PR>`
|
||||
- `scripts/pr review-checkout-main <PR>` or `scripts/pr review-checkout-pr <PR>` while reviewing
|
||||
- `scripts/pr review-guard <PR>` before writing review outputs
|
||||
- `scripts/pr review-validate-artifacts <PR>` after writing outputs
|
||||
- `scripts/pr-prepare init <PR>`
|
||||
- `scripts/pr-prepare validate-commit <PR>`
|
||||
- `scripts/pr-prepare gates <PR>`
|
||||
- `scripts/pr-prepare push <PR>`
|
||||
- Optional one-shot prepare: `scripts/pr-prepare run <PR>`
|
||||
- `scripts/pr-merge <PR>` (verify-only; short form remains backward compatible)
|
||||
- `scripts/pr-merge verify <PR>` (verify-only)
|
||||
- Optional one-shot merge: `scripts/pr-merge run <PR>`
|
||||
|
||||
- Pushing branches.
|
||||
- Posting PR comments.
|
||||
- Editing PR metadata (labels, assignees, state).
|
||||
- Merging PRs.
|
||||
- Editing advisory state or publishing advisories.
|
||||
These wrappers run shared preflight checks and generate deterministic artifacts. They are designed to work from repo root or PR worktree cwd.
|
||||
|
||||
Allowed before approval:
|
||||
## Required artifacts
|
||||
|
||||
- Local code changes.
|
||||
- Local tests and validation.
|
||||
- Drafting copy for PR/advisory comments.
|
||||
- Read-only `gh` commands.
|
||||
- `.local/pr-meta.json` and `.local/pr-meta.env` from review init.
|
||||
- `.local/review.md` and `.local/review.json` from review output.
|
||||
- `.local/prep-context.env` and `.local/prep.md` from prepare.
|
||||
- `.local/prep.env` from prepare completion.
|
||||
|
||||
When approved, perform only the approved remote action, then pause for next instruction.
|
||||
## Structured review handoff
|
||||
|
||||
`review-pr` must write `.local/review.json`.
|
||||
In normal skill runs this is handled automatically. Use `scripts/pr review-artifacts-init <PR>` and `scripts/pr review-tests <PR> ...` manually only for debugging or explicit script-only runs.
|
||||
|
||||
Minimum schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"recommendation": "READY FOR /prepare-pr",
|
||||
"findings": [
|
||||
{
|
||||
"id": "F1",
|
||||
"severity": "IMPORTANT",
|
||||
"title": "Missing changelog entry",
|
||||
"area": "CHANGELOG.md",
|
||||
"fix": "Add a Fixes entry for PR #<PR>"
|
||||
}
|
||||
],
|
||||
"tests": {
|
||||
"ran": ["pnpm test -- ..."],
|
||||
"gaps": ["..."],
|
||||
"result": "pass"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`prepare-pr` resolves all `BLOCKER` and `IMPORTANT` findings from this file.
|
||||
|
||||
## Coding Agent
|
||||
|
||||
Use ChatGPT 5.3 Codex High. Fall back to 5.2 Codex High or 5.3 Codex Medium if necessary.
|
||||
|
||||
## PR quality bar
|
||||
|
||||
@@ -53,6 +95,60 @@ When approved, perform only the approved remote action, then pause for next inst
|
||||
- Harden changes. Always evaluate security impact and abuse paths.
|
||||
- Understand the system before changing it. Never make the codebase messier just to clear a PR queue.
|
||||
|
||||
## Rebase and conflict resolution
|
||||
|
||||
Before any substantive review or prep work, **always rebase the PR branch onto current `main` and resolve merge conflicts first**. A PR that cannot cleanly rebase is not ready for review — fix conflicts before evaluating correctness.
|
||||
|
||||
- During `prepare-pr`: rebase onto `main` as the first step, before fixing findings or running gates.
|
||||
- If conflicts are complex or touch areas you do not understand, stop and escalate.
|
||||
- Prefer **rebase** for linear history; **squash** when commit history is messy or unhelpful.
|
||||
|
||||
## Commit and changelog rules
|
||||
|
||||
- In normal `prepare-pr` runs, commits are created via `scripts/committer "<msg>" <file...>`. Use it manually only when operating outside the skill flow; avoid manual `git add`/`git commit` so staging stays scoped.
|
||||
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
|
||||
- During `prepare-pr`, use this commit subject format: `fix: <summary> (openclaw#<PR>) thanks @<pr-author>`.
|
||||
- Group related changes; avoid bundling unrelated refactors.
|
||||
- Changelog workflow: keep the latest released version at the top (no `Unreleased`); after publishing, bump the version and start a new top section.
|
||||
- When working on a PR: add a changelog entry with the PR number and thank the contributor (mandatory in this workflow).
|
||||
- When working on an issue: reference the issue in the changelog entry.
|
||||
- In this workflow, changelog is always required even for internal/test-only changes.
|
||||
|
||||
## Gate policy
|
||||
|
||||
In fresh worktrees, dependency bootstrap is handled by wrappers before local gates. Manual equivalent:
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
Gate set:
|
||||
|
||||
- Always: `pnpm build`, `pnpm check`
|
||||
- `pnpm test` required unless high-confidence docs-only criteria pass.
|
||||
|
||||
## Co-contributor and clawtributors
|
||||
|
||||
- If we squash, add the PR author as a co-contributor in the commit body using a `Co-authored-by:` trailer.
|
||||
- When maintainer prepares and merges the PR, add the maintainer as an additional `Co-authored-by:` trailer too.
|
||||
- Avoid `--auto` merges for maintainer landings. Merge only after checks are green so the maintainer account is the actor and attribution is deterministic.
|
||||
- For squash merges, set `--author-email` to a reviewer-owned email with fallback candidates; if merge fails due to author-email validation, retry once with the next candidate.
|
||||
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
|
||||
- When merging a PR: leave a PR comment that explains exactly what we did, include the SHA hashes, and record the comment URL in the final report.
|
||||
- Manual post-merge step for new contributors: run `bun scripts/update-clawtributors.ts` to add their avatar to the README "Thanks to all clawtributors" list, then commit the regenerated README.
|
||||
|
||||
## Review mode vs landing mode
|
||||
|
||||
- **Review mode (PR link only):** read `gh pr view`/`gh pr diff`; **do not** switch branches; **do not** change code.
|
||||
- **Landing mode (exception path):** use only when normal `review-pr -> prepare-pr -> merge-pr` flow cannot safely preserve attribution or cannot satisfy branch protection. Create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm build && pnpm check && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: the contributor needs to be in the git graph after this!
|
||||
|
||||
## Pre-review safety checks
|
||||
|
||||
- Before starting a review when a GH Issue/PR is pasted: `review-pr`/`scripts/pr-review` should create and use an isolated `.worktrees/pr-<PR>` checkout from `origin/main` automatically. Do not require a clean main checkout, and do not run `git pull` in a dirty main checkout.
|
||||
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
|
||||
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
|
||||
- Read `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr)) for what we expect from contributors.
|
||||
|
||||
## Unified workflow
|
||||
|
||||
Entry criteria:
|
||||
@@ -78,7 +174,6 @@ Maintainer checkpoint before `prepare-pr`:
|
||||
```
|
||||
What problem are they trying to solve?
|
||||
What is the most optimal implementation?
|
||||
Is the code properly scoped?
|
||||
Can we fix up everything?
|
||||
Do we have any questions?
|
||||
```
|
||||
@@ -94,27 +189,30 @@ Stop and escalate instead of continuing if:
|
||||
Purpose:
|
||||
|
||||
- Make the PR merge-ready on its head branch.
|
||||
- Rebase onto current `main`, fix blocker/important findings, and run gates.
|
||||
- Rebase onto current `main` first, then fix blocker/important findings, then run gates.
|
||||
- In fresh worktrees, bootstrap dependencies before local gates (`pnpm install --frozen-lockfile`).
|
||||
|
||||
Expected output:
|
||||
|
||||
- Updated code and tests on the PR head branch.
|
||||
- `.local/prep.md` with changes, verification, and current HEAD SHA.
|
||||
- Final status: `PR is ready for /mergepr`.
|
||||
- Final status: `PR is ready for /merge-pr`.
|
||||
|
||||
Maintainer checkpoint before `merge-pr`:
|
||||
|
||||
```
|
||||
Is this the most optimal implementation?
|
||||
Is the code properly scoped?
|
||||
Is the code properly reusing existing logic in the codebase?
|
||||
Is the code properly typed?
|
||||
Is the code hardened?
|
||||
Do we have enough tests?
|
||||
Are tests using fake timers where relevant? (e.g., debounce/throttle, retry backoff, timeout branches, delayed callbacks, polling loops)
|
||||
Do we need regression tests?
|
||||
Are tests using fake timers where appropriate? (e.g., debounce/throttle, retry backoff, timeout branches, delayed callbacks, polling loops)
|
||||
Do not add performative tests, ensure tests are real and there are no regressions.
|
||||
Take your time, fix it properly, refactor if necessary.
|
||||
Do you see any follow-up refactors we should do?
|
||||
Did any changes introduce any potential security vulnerabilities?
|
||||
Take your time, fix it properly, refactor if necessary.
|
||||
```
|
||||
|
||||
Stop and escalate instead of continuing if:
|
||||
@@ -123,59 +221,29 @@ Stop and escalate instead of continuing if:
|
||||
- Fixing findings requires broad architecture changes outside safe PR scope.
|
||||
- Security hardening requirements remain unresolved.
|
||||
|
||||
### Security advisory companion flow
|
||||
|
||||
Use this for GHSA-linked fixes and private reports.
|
||||
|
||||
1. Implement and test the fix locally first, do not edit advisory content yet.
|
||||
2. Land the code fix PR through normal flow, including attribution and changelog where needed.
|
||||
3. Prepare public-safe advisory text:
|
||||
- No internal workflow chatter.
|
||||
- No unnecessary exploit detail.
|
||||
- Clear impact, affected range, fixed range, remediation, credits.
|
||||
4. In GitHub advisory UI, set package ranges in the structured fields:
|
||||
- `Affected versions`: `< fixed_version`
|
||||
- `Patched versions`: `>= fixed_version`
|
||||
Do not rely on description text alone.
|
||||
5. If collaborator can edit text but cannot change advisory state, hand off to a Publisher to move triage -> accepted draft -> publish.
|
||||
6. Advisory comments are posted manually in UI when required by policy. Do not rely on `gh api` automation for advisory comments.
|
||||
|
||||
Maintainer checkpoint for security advisories:
|
||||
|
||||
- Is the rewrite public-safe and free of internal/process notes?
|
||||
- Are affected and patched ranges correctly set in the advisory form fields?
|
||||
- Are credits present and accurate?
|
||||
- Do we have Publisher action if state controls are unavailable?
|
||||
|
||||
### 3) `merge-pr`
|
||||
|
||||
Purpose:
|
||||
|
||||
- Merge only after review and prep artifacts are present and checks are green.
|
||||
- Use squash merge flow and verify the PR ends in `MERGED` state.
|
||||
- Use deterministic squash merge flow (`--match-head-commit` + explicit subject/body with co-author trailer), then verify the PR ends in `MERGED` state.
|
||||
- If no required checks are configured on the PR, treat that as acceptable and continue after branch-up-to-date validation.
|
||||
|
||||
Go or no-go checklist before merge:
|
||||
|
||||
- All BLOCKER and IMPORTANT findings are resolved.
|
||||
- Verification is meaningful and regression risk is acceptably low.
|
||||
- Docs and changelog are updated when required.
|
||||
- Changelog is updated (mandatory) and docs are updated when required.
|
||||
- Required CI checks are green and the branch is not behind `main`.
|
||||
|
||||
Expected output:
|
||||
|
||||
- Successful merge commit and recorded merge SHA.
|
||||
- Worktree cleanup after successful merge.
|
||||
- Comment on PR indicating merge was successful.
|
||||
|
||||
Maintainer checkpoint after merge:
|
||||
|
||||
- Were any refactors intentionally deferred and now need follow-up issue(s)?
|
||||
- Did this reveal broader architecture or test gaps we should address?
|
||||
|
||||
## Chasing main mitigation
|
||||
|
||||
To reduce repeated "branch behind main" loops:
|
||||
|
||||
1. Keep prep and merge windows short.
|
||||
2. Rebase/update once, as late as possible, right before final checks.
|
||||
3. Avoid non-essential commits on the PR branch after checks start.
|
||||
4. Prefer merge queue or auto-merge when available.
|
||||
- Run `bun scripts/update-clawtributors.ts` if the contributor is new.
|
||||
|
||||
@@ -1,182 +1,98 @@
|
||||
---
|
||||
name: merge-pr
|
||||
description: Merge a GitHub PR via squash after /preparepr. Use when asked to merge a ready PR. Do not push to main or modify code. Ensure the PR ends in MERGED state and clean up worktrees after success.
|
||||
description: Script-first deterministic squash merge with strict required-check gating, head-SHA pinning, and reliable attribution/commenting.
|
||||
---
|
||||
|
||||
# Merge PR
|
||||
|
||||
## Overview
|
||||
|
||||
Merge a prepared PR via `gh pr merge --squash` and clean up the worktree after success.
|
||||
Merge a prepared PR only after deterministic validation.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Ask for PR number or URL.
|
||||
- If missing, auto-detect from conversation.
|
||||
- If ambiguous, ask.
|
||||
- If missing, use `.local/prep.env` from the PR worktree.
|
||||
|
||||
## Safety
|
||||
|
||||
- Use `gh pr merge --squash` as the only path to `main`.
|
||||
- Do not run `git push` at all during merge.
|
||||
- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792.
|
||||
- Do not execute merge or PR-comment GitHub write actions until maintainer explicitly approves.
|
||||
- Never use `gh pr merge --auto` in this flow.
|
||||
- Never run `git push` directly.
|
||||
- Require `--match-head-commit` during merge.
|
||||
|
||||
## Execution Rule
|
||||
## Execution Contract
|
||||
|
||||
- Execute the workflow. Do not stop after printing the TODO checklist.
|
||||
- If delegating, require the delegate to run commands and capture outputs.
|
||||
|
||||
## Known Footguns
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
|
||||
- Read `.local/review.md` and `.local/prep.md` in the worktree. Do not skip.
|
||||
- Clean up the real worktree directory `.worktrees/pr-<PR>` only after a successful merge.
|
||||
- Expect cleanup to remove `.local/` artifacts.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Ensure `gh pr merge` succeeds.
|
||||
- Ensure PR state is `MERGED`, never `CLOSED`.
|
||||
- Record the merge SHA.
|
||||
- Run cleanup only after merge success.
|
||||
|
||||
## First: Create a TODO Checklist
|
||||
|
||||
Create a checklist of all merge steps, print it, then continue and execute the commands.
|
||||
|
||||
## Setup: Use a Worktree
|
||||
|
||||
Use an isolated worktree for all merge work.
|
||||
1. Validate merge readiness:
|
||||
|
||||
```sh
|
||||
cd ~/dev/openclaw
|
||||
# Sanity: confirm you are in the repo
|
||||
git rev-parse --show-toplevel
|
||||
|
||||
WORKTREE_DIR=".worktrees/pr-<PR>"
|
||||
scripts/pr-merge verify <PR>
|
||||
```
|
||||
|
||||
Run all commands inside the worktree directory.
|
||||
|
||||
## Load Local Artifacts (Mandatory)
|
||||
|
||||
Expect these files from earlier steps:
|
||||
|
||||
- `.local/review.md` from `/reviewpr`
|
||||
- `.local/prep.md` from `/preparepr`
|
||||
Backward-compatible verify form also works:
|
||||
|
||||
```sh
|
||||
ls -la .local || true
|
||||
|
||||
if [ -f .local/review.md ]; then
|
||||
echo "Found .local/review.md"
|
||||
sed -n '1,120p' .local/review.md
|
||||
else
|
||||
echo "Missing .local/review.md. Stop and run /reviewpr, then /preparepr."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f .local/prep.md ]; then
|
||||
echo "Found .local/prep.md"
|
||||
sed -n '1,120p' .local/prep.md
|
||||
else
|
||||
echo "Missing .local/prep.md. Stop and run /preparepr first."
|
||||
exit 1
|
||||
fi
|
||||
scripts/pr-merge <PR>
|
||||
```
|
||||
|
||||
2. Run one-shot deterministic merge:
|
||||
|
||||
```sh
|
||||
scripts/pr-merge run <PR>
|
||||
```
|
||||
|
||||
3. Ensure output reports:
|
||||
|
||||
- `merge_sha=<sha>`
|
||||
- `merge_author_email=<email>`
|
||||
- `comment_url=<url>`
|
||||
|
||||
## Steps
|
||||
|
||||
1. Identify PR meta
|
||||
1. Validate artifacts
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,state,isDraft,author,headRefName,baseRefName,headRepository,body --jq '{number,title,state,isDraft,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner,body}'
|
||||
contrib=$(gh pr view <PR> --json author --jq .author.login)
|
||||
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
|
||||
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
|
||||
require=(.local/review.md .local/review.json .local/prep.md .local/prep.env)
|
||||
for f in "${require[@]}"; do
|
||||
[ -s "$f" ] || { echo "Missing artifact: $f"; exit 1; }
|
||||
done
|
||||
```
|
||||
|
||||
2. Run sanity checks
|
||||
|
||||
Stop if any are true:
|
||||
|
||||
- PR is a draft.
|
||||
- Required checks are failing.
|
||||
- Branch is behind main.
|
||||
2. Validate checks and branch status
|
||||
|
||||
```sh
|
||||
# Checks
|
||||
gh pr checks <PR>
|
||||
|
||||
# Check behind main
|
||||
git fetch origin main
|
||||
git fetch origin pull/<PR>/head:pr-<PR>
|
||||
git merge-base --is-ancestor origin/main pr-<PR> || echo "PR branch is behind main, run /preparepr"
|
||||
scripts/pr-merge verify <PR>
|
||||
source .local/prep.env
|
||||
```
|
||||
|
||||
If anything is failing or behind, stop and say to run `/preparepr`.
|
||||
`scripts/pr-merge` treats “no required checks configured” as acceptable (`[]`), but fails on any required `fail` or `pending`.
|
||||
|
||||
3. Merge PR and delete branch
|
||||
|
||||
If checks are still running, use `--auto` to queue the merge.
|
||||
3. Merge deterministically (wrapper-managed)
|
||||
|
||||
```sh
|
||||
# Check status first
|
||||
check_status=$(gh pr checks <PR> 2>&1)
|
||||
if echo "$check_status" | grep -q "pending\|queued"; then
|
||||
echo "Checks still running, using --auto to queue merge"
|
||||
gh pr merge <PR> --squash --delete-branch --auto
|
||||
echo "Merge queued. Monitor with: gh pr checks <PR> --watch"
|
||||
else
|
||||
gh pr merge <PR> --squash --delete-branch
|
||||
fi
|
||||
scripts/pr-merge run <PR>
|
||||
```
|
||||
|
||||
Before running merge command, pause and ask for explicit maintainer go-ahead.
|
||||
`scripts/pr-merge run` performs:
|
||||
|
||||
If merge fails, report the error and stop. Do not retry in a loop.
|
||||
If the PR needs changes beyond what `/preparepr` already did, stop and say to run `/preparepr` again.
|
||||
- deterministic squash merge pinned to `PREP_HEAD_SHA`
|
||||
- reviewer merge author email selection with fallback candidates
|
||||
- one retry only when merge fails due to author-email validation
|
||||
- co-author trailers for PR author and reviewer
|
||||
- post-merge verification of both co-author trailers on commit message
|
||||
- PR comment retry (3 attempts), then comment URL extraction
|
||||
- cleanup after confirmed `MERGED`
|
||||
|
||||
4. Get merge SHA
|
||||
4. Manual fallback (only if wrapper is unavailable)
|
||||
|
||||
```sh
|
||||
merge_sha=$(gh pr view <PR> --json mergeCommit --jq '.mergeCommit.oid')
|
||||
echo "merge_sha=$merge_sha"
|
||||
scripts/pr merge-run <PR>
|
||||
```
|
||||
|
||||
5. Optional comment
|
||||
5. Cleanup
|
||||
|
||||
Use a literal multiline string or heredoc for newlines.
|
||||
|
||||
```sh
|
||||
gh pr comment <PR> --body "$(printf 'Merged via squash.\n\n- Merge commit: %s\n\nThanks @%s!\n' \"$merge_sha\" \"$contrib\")"
|
||||
```
|
||||
|
||||
6. Verify PR state is MERGED
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json state --jq .state
|
||||
```
|
||||
|
||||
7. Clean up worktree only on success
|
||||
|
||||
Run cleanup only if step 6 returned `MERGED`.
|
||||
|
||||
```sh
|
||||
cd ~/dev/openclaw
|
||||
|
||||
git worktree remove ".worktrees/pr-<PR>" --force
|
||||
|
||||
git branch -D temp/pr-<PR> 2>/dev/null || true
|
||||
git branch -D pr-<PR> 2>/dev/null || true
|
||||
```
|
||||
Cleanup is handled by `run` after merge success.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Worktree only.
|
||||
- Do not close PRs.
|
||||
- End in MERGED state.
|
||||
- Clean up only after merge success.
|
||||
- Never push to main. Use `gh pr merge --squash` only.
|
||||
- Do not run `git push` at all in this command.
|
||||
- End in `MERGED`, never `CLOSED`.
|
||||
- Cleanup only after confirmed merge.
|
||||
|
||||
@@ -1,251 +1,131 @@
|
||||
---
|
||||
name: prepare-pr
|
||||
description: Prepare a GitHub PR for merge by rebasing onto main, fixing review findings, running gates, committing fixes, and pushing to the PR head branch. Use after /reviewpr. Never merge or push to main.
|
||||
description: Script-first PR preparation with structured findings resolution, deterministic push safety, and explicit gate execution.
|
||||
---
|
||||
|
||||
# Prepare PR
|
||||
|
||||
## Overview
|
||||
|
||||
Prepare a PR branch for merge with review fixes, green gates, and an updated head branch.
|
||||
Prepare the PR head branch for merge after `/review-pr`.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Ask for PR number or URL.
|
||||
- If missing, auto-detect from conversation.
|
||||
- If ambiguous, ask.
|
||||
- If missing, use `.local/pr-meta.env` if present in the PR worktree.
|
||||
|
||||
## Safety
|
||||
|
||||
- Never push to `main` or `origin/main`. Push only to the PR head branch.
|
||||
- Never run `git push` without specifying remote and branch explicitly. Do not run bare `git push`.
|
||||
- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792.
|
||||
- Never push to `main`.
|
||||
- Only push to PR head with explicit `--force-with-lease` against known head SHA.
|
||||
- Do not run `git clean -fdx`.
|
||||
- Do not run `git add -A` or `git add .`. Stage only specific files changed.
|
||||
- Do not push to GitHub until the maintainer explicitly approves the push step.
|
||||
- Wrappers are cwd-agnostic; run from repo root or PR worktree.
|
||||
|
||||
## Execution Rule
|
||||
## Execution Contract
|
||||
|
||||
- Execute the workflow. Do not stop after printing the TODO checklist.
|
||||
- If delegating, require the delegate to run commands and capture outputs.
|
||||
|
||||
## Known Footguns
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
|
||||
- Do not run `git clean -fdx`.
|
||||
- Do not run `git add -A` or `git add .`.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Rebase PR commits onto `origin/main`.
|
||||
- Fix all BLOCKER and IMPORTANT items from `.local/review.md`.
|
||||
- Run gates and pass.
|
||||
- Commit prep changes.
|
||||
- Push the updated HEAD back to the PR head branch.
|
||||
- Write `.local/prep.md` with a prep summary.
|
||||
- Output exactly: `PR is ready for /mergepr`.
|
||||
|
||||
## First: Create a TODO Checklist
|
||||
|
||||
Create a checklist of all prep steps, print it, then continue and execute the commands.
|
||||
|
||||
## Setup: Use a Worktree
|
||||
|
||||
Use an isolated worktree for all prep work.
|
||||
1. Run setup:
|
||||
|
||||
```sh
|
||||
cd ~/openclaw
|
||||
# Sanity: confirm you are in the repo
|
||||
git rev-parse --show-toplevel
|
||||
|
||||
WORKTREE_DIR=".worktrees/pr-<PR>"
|
||||
scripts/pr-prepare init <PR>
|
||||
```
|
||||
|
||||
Run all commands inside the worktree directory.
|
||||
2. Resolve findings from structured review:
|
||||
|
||||
## Load Review Findings (Mandatory)
|
||||
- `.local/review.json` is mandatory.
|
||||
- Resolve all `BLOCKER` and `IMPORTANT` items.
|
||||
|
||||
3. Commit with required subject format and validate it.
|
||||
|
||||
4. Run gates via wrapper.
|
||||
|
||||
5. Push via wrapper (includes pre-push remote verification, one automatic lease-retry path, and post-push API propagation retry).
|
||||
|
||||
Optional one-shot path:
|
||||
|
||||
```sh
|
||||
if [ -f .local/review.md ]; then
|
||||
echo "Found review findings from /reviewpr"
|
||||
else
|
||||
echo "Missing .local/review.md. Run /reviewpr first and save findings."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Read it
|
||||
sed -n '1,200p' .local/review.md
|
||||
scripts/pr-prepare run <PR>
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
1. Identify PR meta (author, head branch, head repo URL)
|
||||
1. Setup and artifacts
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,author,headRefName,baseRefName,headRepository,body --jq '{number,title,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner,body}'
|
||||
contrib=$(gh pr view <PR> --json author --jq .author.login)
|
||||
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
|
||||
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
|
||||
scripts/pr-prepare init <PR>
|
||||
|
||||
ls -la .local/review.md .local/review.json .local/pr-meta.env .local/prep-context.env
|
||||
jq . .local/review.json >/dev/null
|
||||
```
|
||||
|
||||
2. Fetch the PR branch tip into a local ref
|
||||
2. Resolve required findings
|
||||
|
||||
List required items:
|
||||
|
||||
```sh
|
||||
git fetch origin pull/<PR>/head:pr-<PR>
|
||||
jq -r '.findings[] | select(.severity=="BLOCKER" or .severity=="IMPORTANT") | "- [\(.severity)] \(.id): \(.title) => \(.fix)"' .local/review.json
|
||||
```
|
||||
|
||||
3. Rebase PR commits onto latest main
|
||||
Fix all required findings. Keep scope tight.
|
||||
|
||||
3. Update changelog/docs (changelog is mandatory in this workflow)
|
||||
|
||||
```sh
|
||||
# Move worktree to the PR tip first
|
||||
git reset --hard pr-<PR>
|
||||
|
||||
# Rebase onto current main
|
||||
git fetch origin main
|
||||
git rebase origin/main
|
||||
jq -r '.changelog' .local/review.json
|
||||
jq -r '.docs' .local/review.json
|
||||
```
|
||||
|
||||
If conflicts happen:
|
||||
4. Commit scoped changes
|
||||
|
||||
- Resolve each conflicted file.
|
||||
- Run `git add <resolved_file>` for each file.
|
||||
- Run `git rebase --continue`.
|
||||
Required commit subject format:
|
||||
|
||||
If the rebase gets confusing or you resolve conflicts 3 or more times, stop and report.
|
||||
- `fix: <summary> (openclaw#<PR>) thanks @<pr-author>`
|
||||
|
||||
4. Fix issues from `.local/review.md`
|
||||
|
||||
- Fix all BLOCKER and IMPORTANT items.
|
||||
- NITs are optional.
|
||||
- Keep scope tight.
|
||||
|
||||
Keep a running log in `.local/prep.md`:
|
||||
|
||||
- List which review items you fixed.
|
||||
- List which files you touched.
|
||||
- Note behavior changes.
|
||||
|
||||
5. Update `CHANGELOG.md` if flagged in review
|
||||
|
||||
Check `.local/review.md` section H for guidance.
|
||||
If flagged and user-facing:
|
||||
|
||||
- Check if `CHANGELOG.md` exists.
|
||||
Use explicit file list:
|
||||
|
||||
```sh
|
||||
ls CHANGELOG.md 2>/dev/null
|
||||
source .local/pr-meta.env
|
||||
scripts/committer "fix: <summary> (openclaw#$PR_NUMBER) thanks @$PR_AUTHOR" <file1> <file2> ...
|
||||
```
|
||||
|
||||
- Follow existing format.
|
||||
- Add a concise entry with PR number and contributor.
|
||||
|
||||
6. Update docs if flagged in review
|
||||
|
||||
Check `.local/review.md` section G for guidance.
|
||||
If flagged, update only docs related to the PR changes.
|
||||
|
||||
7. Commit prep fixes
|
||||
|
||||
Stage only specific files:
|
||||
Validate commit subject:
|
||||
|
||||
```sh
|
||||
git add <file1> <file2> ...
|
||||
scripts/pr-prepare validate-commit <PR>
|
||||
```
|
||||
|
||||
Preferred commit tool:
|
||||
5. Run gates
|
||||
|
||||
```sh
|
||||
committer "fix: <summary> (#<PR>) (thanks @$contrib)" <changed files>
|
||||
scripts/pr-prepare gates <PR>
|
||||
```
|
||||
|
||||
If `committer` is not found:
|
||||
6. Push safely to PR head
|
||||
|
||||
```sh
|
||||
git commit -m "fix: <summary> (#<PR>) (thanks @$contrib)"
|
||||
scripts/pr-prepare push <PR>
|
||||
```
|
||||
|
||||
8. Run full gates before pushing
|
||||
This push step includes:
|
||||
|
||||
- robust fork remote resolution from owner/name,
|
||||
- pre-push remote SHA verification,
|
||||
- one automatic rebase + gate rerun + retry if lease push fails,
|
||||
- post-push PR-head propagation retry,
|
||||
- idempotent behavior when local prep HEAD is already on the PR head,
|
||||
- post-push SHA verification and `.local/prep.env` generation.
|
||||
|
||||
7. Verify handoff artifacts
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm build
|
||||
pnpm ui:build
|
||||
pnpm check
|
||||
pnpm test
|
||||
ls -la .local/prep.md .local/prep.env
|
||||
```
|
||||
|
||||
Require all to pass. If something fails, fix, commit, and rerun. Allow at most 3 fix and rerun cycles. If gates still fail after 3 attempts, stop and report the failures. Do not loop indefinitely.
|
||||
8. Output
|
||||
|
||||
9. Push updates back to the PR head branch
|
||||
|
||||
```sh
|
||||
# Ensure remote for PR head exists
|
||||
git remote add prhead "$head_repo_url.git" 2>/dev/null || git remote set-url prhead "$head_repo_url.git"
|
||||
|
||||
# Use force with lease after rebase
|
||||
# Double check: $head must NOT be "main" or "master"
|
||||
echo "Pushing to branch: $head"
|
||||
if [ "$head" = "main" ] || [ "$head" = "master" ]; then
|
||||
echo "ERROR: head branch is main/master. This is wrong. Stopping."
|
||||
exit 1
|
||||
fi
|
||||
git push --force-with-lease prhead HEAD:$head
|
||||
```
|
||||
|
||||
Before running the command above, pause and ask for explicit maintainer go-ahead to perform the push.
|
||||
|
||||
10. Verify PR is not behind main (Mandatory)
|
||||
|
||||
```sh
|
||||
git fetch origin main
|
||||
git fetch origin pull/<PR>/head:pr-<PR>-verify --force
|
||||
git merge-base --is-ancestor origin/main pr-<PR>-verify && echo "PR is up to date with main" || echo "ERROR: PR is still behind main, rebase again"
|
||||
git branch -D pr-<PR>-verify 2>/dev/null || true
|
||||
```
|
||||
|
||||
If still behind main, repeat steps 2 through 9.
|
||||
|
||||
11. Write prep summary artifacts (Mandatory)
|
||||
|
||||
Update `.local/prep.md` with:
|
||||
|
||||
- Current HEAD sha from `git rev-parse HEAD`.
|
||||
- Short bullet list of changes.
|
||||
- Gate results.
|
||||
- Push confirmation.
|
||||
- Rebase verification result.
|
||||
|
||||
Create or overwrite `.local/prep.md` and verify it exists and is non-empty:
|
||||
|
||||
```sh
|
||||
git rev-parse HEAD
|
||||
ls -la .local/prep.md
|
||||
wc -l .local/prep.md
|
||||
```
|
||||
|
||||
12. Output
|
||||
|
||||
Include a diff stat summary:
|
||||
|
||||
```sh
|
||||
git diff --stat origin/main..HEAD
|
||||
git diff --shortstat origin/main..HEAD
|
||||
```
|
||||
|
||||
Report totals: X files changed, Y insertions(+), Z deletions(-).
|
||||
|
||||
If gates passed and push succeeded, print exactly:
|
||||
|
||||
```
|
||||
PR is ready for /mergepr
|
||||
```
|
||||
|
||||
Otherwise, list remaining failures and stop.
|
||||
- Summarize resolved findings and gate results.
|
||||
- Print exactly: `PR is ready for /merge-pr`.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Worktree only.
|
||||
- Do not delete the worktree on success. `/mergepr` may reuse it.
|
||||
- Do not run `gh pr merge`.
|
||||
- Never push to main. Only push to the PR head branch.
|
||||
- Run and pass all gates before pushing.
|
||||
- Do not run `gh pr merge` in this skill.
|
||||
- Do not delete worktree.
|
||||
|
||||
@@ -1,229 +1,141 @@
|
||||
---
|
||||
name: review-pr
|
||||
description: Review-only GitHub pull request analysis with the gh CLI. Use when asked to review a PR, provide structured feedback, or assess readiness to land. Do not merge, push, or make code changes you intend to keep.
|
||||
description: Script-first review-only GitHub pull request analysis. Use for deterministic PR review with structured findings handoff to /prepare-pr.
|
||||
---
|
||||
|
||||
# Review PR
|
||||
|
||||
## Overview
|
||||
|
||||
Perform a thorough review-only PR assessment and return a structured recommendation on readiness for /preparepr.
|
||||
Perform a read-only review and produce both human and machine-readable outputs.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Ask for PR number or URL.
|
||||
- If missing, always ask. Never auto-detect from conversation.
|
||||
- If ambiguous, ask.
|
||||
- If missing, always ask.
|
||||
|
||||
## Safety
|
||||
|
||||
- Never push to `main` or `origin/main`, not during review, not ever.
|
||||
- Do not run `git push` at all during review. Treat review as read only.
|
||||
- Do not stop or kill the gateway. Do not run gateway stop commands. Do not kill processes on port 18792.
|
||||
- Do not perform any GitHub write action (comments, assignees, labels, state changes) unless maintainer explicitly approves it.
|
||||
- Never push, merge, or modify code intended to keep.
|
||||
- Work only in `.worktrees/pr-<PR>`.
|
||||
|
||||
## Execution Rule
|
||||
## Execution Contract
|
||||
|
||||
- Execute the workflow. Do not stop after printing the TODO checklist.
|
||||
- If delegating, require the delegate to run commands and capture outputs, not a plan.
|
||||
|
||||
## Known Failure Modes
|
||||
|
||||
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
|
||||
- Do not stop after printing the checklist. That is not completion.
|
||||
|
||||
## Writing Style for Output
|
||||
|
||||
- Write casual and direct.
|
||||
- Avoid em dashes and en dashes. Use commas or separate sentences.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
- Run the commands in the worktree and inspect the PR directly.
|
||||
- Produce the structured review sections A through J.
|
||||
- Save the full review to `.local/review.md` inside the worktree.
|
||||
|
||||
## First: Create a TODO Checklist
|
||||
|
||||
Create a checklist of all review steps, print it, then continue and execute the commands.
|
||||
|
||||
## Setup: Use a Worktree
|
||||
|
||||
Use an isolated worktree for all review work.
|
||||
1. Run wrapper setup:
|
||||
|
||||
```sh
|
||||
cd ~/dev/openclaw
|
||||
# Sanity: confirm you are in the repo
|
||||
git rev-parse --show-toplevel
|
||||
|
||||
WORKTREE_DIR=".worktrees/pr-<PR>"
|
||||
git fetch origin main
|
||||
|
||||
# Reuse existing worktree if it exists, otherwise create new
|
||||
if [ -d "$WORKTREE_DIR" ]; then
|
||||
cd "$WORKTREE_DIR"
|
||||
git checkout temp/pr-<PR> 2>/dev/null || git checkout -b temp/pr-<PR>
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
else
|
||||
git worktree add "$WORKTREE_DIR" -b temp/pr-<PR> origin/main
|
||||
cd "$WORKTREE_DIR"
|
||||
fi
|
||||
|
||||
# Create local scratch space that persists across /reviewpr to /preparepr to /mergepr
|
||||
mkdir -p .local
|
||||
scripts/pr-review <PR>
|
||||
```
|
||||
|
||||
Run all commands inside the worktree directory.
|
||||
Start on `origin/main` so you can check for existing implementations before looking at PR code.
|
||||
2. Use explicit branch mode switches:
|
||||
|
||||
- Main baseline mode: `scripts/pr review-checkout-main <PR>`
|
||||
- PR-head mode: `scripts/pr review-checkout-pr <PR>`
|
||||
|
||||
3. Before writing review outputs, run branch guard:
|
||||
|
||||
```sh
|
||||
scripts/pr review-guard <PR>
|
||||
```
|
||||
|
||||
4. Write both outputs:
|
||||
|
||||
- `.local/review.md` with sections A through J.
|
||||
- `.local/review.json` with structured findings.
|
||||
|
||||
5. Validate artifacts semantically:
|
||||
|
||||
```sh
|
||||
scripts/pr review-validate-artifacts <PR>
|
||||
```
|
||||
|
||||
## Steps
|
||||
|
||||
1. Identify PR meta and context
|
||||
1. Setup and metadata
|
||||
|
||||
```sh
|
||||
gh pr view <PR> --json number,title,state,isDraft,author,baseRefName,headRefName,headRepository,url,body,labels,assignees,reviewRequests,files,additions,deletions --jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headRepo:.headRepository.nameWithOwner,additions,deletions,files:.files|length,body}'
|
||||
scripts/pr-review <PR>
|
||||
ls -la .local/pr-meta.json .local/pr-meta.env .local/review-context.env .local/review-mode.env
|
||||
```
|
||||
|
||||
2. Check if this already exists in main before looking at the PR branch
|
||||
|
||||
- Identify the core feature or fix from the PR title and description.
|
||||
- Search for existing implementations using keywords from the PR title, changed file paths, and function or component names from the diff.
|
||||
2. Existing implementation check on main
|
||||
|
||||
```sh
|
||||
# Use keywords from the PR title and changed files
|
||||
rg -n "<keyword_from_pr_title>" -S src packages apps ui || true
|
||||
rg -n "<function_or_component_name>" -S src packages apps ui || true
|
||||
|
||||
git log --oneline --all --grep="<keyword_from_pr_title>" | head -20
|
||||
scripts/pr review-checkout-main <PR>
|
||||
rg -n "<keyword>" -S src extensions apps || true
|
||||
git log --oneline --all --grep "<keyword>" | head -20
|
||||
```
|
||||
|
||||
If it already exists, call it out as a BLOCKER or at least IMPORTANT.
|
||||
|
||||
3. Optional claim step, only with explicit approval
|
||||
|
||||
If the maintainer asks to claim the PR, assign yourself. Otherwise skip this.
|
||||
3. Claim PR
|
||||
|
||||
```sh
|
||||
gh_user=$(gh api user --jq .login)
|
||||
gh pr edit <PR> --add-assignee "$gh_user"
|
||||
gh pr edit <PR> --add-assignee "$gh_user" || echo "Could not assign reviewer, continuing"
|
||||
```
|
||||
|
||||
4. Read the PR description carefully
|
||||
|
||||
Use the body from step 1. Summarize goal, scope, and missing context.
|
||||
|
||||
5. Read the diff thoroughly
|
||||
|
||||
Minimum:
|
||||
4. Read PR description and diff
|
||||
|
||||
```sh
|
||||
scripts/pr review-checkout-pr <PR>
|
||||
gh pr diff <PR>
|
||||
|
||||
source .local/review-context.env
|
||||
git diff --stat "$MERGE_BASE"..pr-<PR>
|
||||
git diff "$MERGE_BASE"..pr-<PR>
|
||||
```
|
||||
|
||||
If you need full code context locally, fetch the PR head to a local ref and diff it. Do not create a merge commit.
|
||||
5. Optional local tests
|
||||
|
||||
Use the wrapper for target validation and executed-test verification:
|
||||
|
||||
```sh
|
||||
git fetch origin pull/<PR>/head:pr-<PR>
|
||||
# Show changes without modifying the working tree
|
||||
|
||||
git diff --stat origin/main..pr-<PR>
|
||||
git diff origin/main..pr-<PR>
|
||||
scripts/pr review-tests <PR> <test-file> [<test-file> ...]
|
||||
```
|
||||
|
||||
If you want to browse the PR version of files directly, temporarily check out `pr-<PR>` in the worktree. Do not commit or push. Return to `temp/pr-<PR>` and reset to `origin/main` afterward.
|
||||
6. Initialize review artifact templates
|
||||
|
||||
```sh
|
||||
# Use only if needed
|
||||
# git checkout pr-<PR>
|
||||
# ...inspect files...
|
||||
|
||||
git checkout temp/pr-<PR>
|
||||
git reset --hard origin/main
|
||||
scripts/pr review-artifacts-init <PR>
|
||||
```
|
||||
|
||||
6. Validate the change is needed and valuable
|
||||
7. Produce review outputs
|
||||
|
||||
Be honest. Call out low value AI slop.
|
||||
- Fill `.local/review.md` sections A through J.
|
||||
- Fill `.local/review.json`.
|
||||
|
||||
7. Evaluate implementation quality
|
||||
Minimum JSON shape:
|
||||
|
||||
Review correctness, design, performance, and ergonomics.
|
||||
```json
|
||||
{
|
||||
"recommendation": "READY FOR /prepare-pr",
|
||||
"findings": [
|
||||
{
|
||||
"id": "F1",
|
||||
"severity": "IMPORTANT",
|
||||
"title": "...",
|
||||
"area": "path/or/component",
|
||||
"fix": "Actionable fix"
|
||||
}
|
||||
],
|
||||
"tests": {
|
||||
"ran": [],
|
||||
"gaps": [],
|
||||
"result": "pass"
|
||||
},
|
||||
"docs": "up_to_date|missing|not_applicable",
|
||||
"changelog": "required"
|
||||
}
|
||||
```
|
||||
|
||||
8. Perform a security review
|
||||
|
||||
Assume OpenClaw subagents run with full disk access, including git, gh, and shell. Check auth, input validation, secrets, dependencies, tool safety, and privacy.
|
||||
|
||||
9. Review tests and verification
|
||||
|
||||
Identify what exists, what is missing, and what would be a minimal regression test.
|
||||
|
||||
10. Check docs
|
||||
|
||||
Check if the PR touches code with related documentation such as README, docs, inline API docs, or config examples.
|
||||
|
||||
- If docs exist for the changed area and the PR does not update them, flag as IMPORTANT.
|
||||
- If the PR adds a new feature or config option with no docs, flag as IMPORTANT.
|
||||
- If the change is purely internal with no user-facing impact, skip this.
|
||||
|
||||
11. Check changelog
|
||||
|
||||
Check if `CHANGELOG.md` exists and whether the PR warrants an entry.
|
||||
|
||||
- If the project has a changelog and the PR is user-facing, flag missing entry as IMPORTANT.
|
||||
- Leave the change for /preparepr, only flag it here.
|
||||
|
||||
12. Answer the key question
|
||||
|
||||
Decide if /preparepr can fix issues or the contributor must update the PR.
|
||||
|
||||
13. Save findings to the worktree
|
||||
|
||||
Write the full structured review sections A through J to `.local/review.md`.
|
||||
Create or overwrite the file and verify it exists and is non-empty.
|
||||
8. Guard + validate before final output
|
||||
|
||||
```sh
|
||||
ls -la .local/review.md
|
||||
wc -l .local/review.md
|
||||
scripts/pr review-guard <PR>
|
||||
scripts/pr review-validate-artifacts <PR>
|
||||
```
|
||||
|
||||
14. Output the structured review
|
||||
|
||||
Produce a review that matches what you saved to `.local/review.md`.
|
||||
|
||||
A) TL;DR recommendation
|
||||
|
||||
- One of: READY FOR /preparepr | NEEDS WORK | NEEDS DISCUSSION | NOT USEFUL (CLOSE)
|
||||
- 1 to 3 sentences.
|
||||
|
||||
B) What changed
|
||||
|
||||
C) What is good
|
||||
|
||||
D) Security findings
|
||||
|
||||
E) Concerns or questions (actionable)
|
||||
|
||||
- Numbered list.
|
||||
- Mark each item as BLOCKER, IMPORTANT, or NIT.
|
||||
- For each, point to file or area and propose a concrete fix.
|
||||
|
||||
F) Tests
|
||||
|
||||
G) Docs status
|
||||
|
||||
- State if related docs are up to date, missing, or not applicable.
|
||||
|
||||
H) Changelog
|
||||
|
||||
- State if `CHANGELOG.md` needs an entry and which category.
|
||||
|
||||
I) Follow ups (optional)
|
||||
|
||||
J) Suggested PR comment (optional)
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Worktree only.
|
||||
- Do not delete the worktree after review.
|
||||
- Review only, do not merge, do not push.
|
||||
- Keep review read-only.
|
||||
- Do not delete worktree.
|
||||
- Use merge-base scoped diff for local context to avoid stale branch drift.
|
||||
|
||||
25
.github/workflows/auto-response.yml
vendored
25
.github/workflows/auto-response.yml
vendored
@@ -89,8 +89,7 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
const isLabelEvent = context.payload.action === "labeled";
|
||||
if (!hasTriggerLabel && !isLabelEvent) {
|
||||
if (!hasTriggerLabel) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -131,8 +130,6 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
const invalidLabel = "invalid";
|
||||
|
||||
const pullRequest = context.payload.pull_request;
|
||||
if (pullRequest) {
|
||||
const labelCount = labelSet.size;
|
||||
@@ -151,26 +148,6 @@ jobs:
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (labelSet.has(invalidLabel)) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pullRequest.number,
|
||||
state: "closed",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (issue && labelSet.has(invalidLabel)) {
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: "closed",
|
||||
state_reason: "not_planned",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const rule = rules.find((item) => labelSet.has(item.label));
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -82,5 +82,4 @@ USER.md
|
||||
/memory/
|
||||
.agent/*.json
|
||||
!.agent/workflows/
|
||||
/local/
|
||||
package-lock.json
|
||||
local/
|
||||
|
||||
@@ -42,9 +42,8 @@ Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` wit
|
||||
- If unclear, ask
|
||||
10. Full gate (BEFORE commit):
|
||||
- `pnpm lint && pnpm build && pnpm test`
|
||||
11. Commit via committer (final merge commit only includes PR # + thanks):
|
||||
- For the final merge-ready commit: `committer "fix: <summary> (#<PR>) (thanks @$contrib)" CHANGELOG.md <changed files>`
|
||||
- If you need intermediate fix commits before the final merge commit, keep those messages concise and **omit** PR number/thanks.
|
||||
11. Commit via committer (include # + contributor in commit message):
|
||||
- `committer "fix: <summary> (#<PR>) (thanks @$contrib)" CHANGELOG.md <changed files>`
|
||||
- `land_sha=$(git rev-parse HEAD)`
|
||||
12. Push updated PR branch (rebase => usually needs force):
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
|
||||
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
|
||||
- Install deps: `pnpm install`
|
||||
- If deps are missing (for example `node_modules` missing, `vitest not found`, or `command not found`), run the repo’s package-manager install command (prefer lockfile/README-defined PM), then rerun the exact requested command once. Apply this to test/build/lint/typecheck/dev commands; if retry still fails, report the command and first actionable error.
|
||||
- Pre-commit hooks: `prek install` (runs same checks as CI)
|
||||
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
|
||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||
@@ -107,10 +106,6 @@
|
||||
|
||||
- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
||||
|
||||
## Git Notes
|
||||
|
||||
- If `git branch -d/-D <branch>` is policy-blocked, delete the local ref directly: `git update-ref -d refs/heads/<branch>`.
|
||||
|
||||
## Security & Configuration Tips
|
||||
|
||||
- Web provider stores creds at `~/.openclaw/credentials/`; rerun `openclaw login` if logged out.
|
||||
|
||||
140
CHANGELOG.md
140
CHANGELOG.md
@@ -2,115 +2,21 @@
|
||||
|
||||
Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.13
|
||||
|
||||
### Changes
|
||||
|
||||
- Discord: send voice messages with waveform previews from local audio files (including silent delivery). (#7253) Thanks @nyanjou.
|
||||
- Discord: add configurable presence status/activity/type/url (custom status defaults to activity text). (#10855) Thanks @h0tp-ftw.
|
||||
- Slack/Plugins: add thread-ownership outbound gating via `message_sending` hooks, including @-mention bypass tracking and Slack outbound hook wiring for cancel/modify behavior. (#15775) Thanks @DarlingtonDeveloper.
|
||||
- Agents: add synthetic catalog support for `hf:zai-org/GLM-5`. (#15867) Thanks @battman21.
|
||||
- Skills: remove duplicate `local-places` Google Places skill/proxy and keep `goplaces` as the single supported Google Places path.
|
||||
- Agents: add pre-prompt context diagnostics (`messages`, `systemPromptChars`, `promptChars`, provider/model, session file) before embedded runner prompt calls to improve overflow debugging. (#8930) Thanks @Glucksberg.
|
||||
- Onboarding/Providers: add first-class Hugging Face Inference provider support (provider wiring, onboarding auth choice/API key flow, and default-model selection), and preserve Hugging Face auth intent in auth-choice remapping (`tokenProvider=huggingface` with `authChoice=apiKey`) while skipping env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp.
|
||||
- Onboarding/Providers: add `minimax-api-key-cn` auth choice for the MiniMax China API endpoint. (#15191) Thanks @liuy.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Config/State: removed legacy `.moltbot` auto-detection/migration and `moltbot.json` config candidates. If you still have state/config under `~/.moltbot`, move it to `~/.openclaw` (recommended) or set `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH` explicitly.
|
||||
## 2026.2.13 (Unreleased)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/Auth: add trusted-proxy mode hardening follow-ups by keeping `OPENCLAW_GATEWAY_*` env compatibility, auto-normalizing invalid setup combinations in interactive `gateway configure` (trusted-proxy forces `bind=lan` and disables Tailscale serve/funnel), and suppressing shared-secret/rate-limit audit findings that do not apply to trusted-proxy deployments. (#15940) Thanks @nickytonline.
|
||||
- Docs/Hooks: update hooks documentation URLs to the new `/automation/hooks` location. (#16165) Thanks @nicholascyh.
|
||||
- Security/Audit: warn when `gateway.tools.allow` re-enables default-denied tools over HTTP `POST /tools/invoke`, since this can increase RCE blast radius if the gateway is reachable.
|
||||
- Feishu: stop persistent Typing reaction on NO_REPLY/suppressed runs by wiring reply-dispatcher cleanup to remove typing indicators. (#15464) Thanks @arosstale.
|
||||
- BlueBubbles: gracefully degrade when Private API is disabled by filtering private-only actions, skipping private-only reactions/reply effects, and avoiding private reply markers so non-private flows remain usable. (#16002) Thanks @L-U-C-K-Y.
|
||||
- Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.
|
||||
- Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u.
|
||||
- Auto-reply/Threading: honor explicit `[[reply_to_*]]` tags even when `replyToMode` is `off`. (#16174) Thanks @aldoeliacim.
|
||||
- Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.
|
||||
- Auto-reply/Media: allow image-only inbound messages (no caption) to reach the agent instead of short-circuiting as empty text, and preserve thread context in queued/followup prompt bodies for media-only runs. (#11916) Thanks @arosstale.
|
||||
- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
|
||||
- Web UI: add `img` to DOMPurify allowed tags and `src`/`alt` to allowed attributes so markdown images render in webchat instead of being stripped. (#15437) Thanks @lailoo.
|
||||
- Telegram/Matrix: treat MP3 and M4A (including `audio/mp4`) as voice-compatible for `asVoice` routing, and keep WAV/AAC falling back to regular audio sends. (#15438) Thanks @azade-c.
|
||||
- WhatsApp: preserve outbound document filenames for web-session document sends instead of always sending `"file"`. (#15594) Thanks @TsekaLuk.
|
||||
- Telegram: cap bot menu registration to Telegram's 100-command limit with an overflow warning while keeping typed hidden commands available. (#15844) Thanks @battman21.
|
||||
- Telegram: scope skill commands to the resolved agent for default accounts so `setMyCommands` no longer triggers `BOT_COMMANDS_TOO_MUCH` when multiple agents are configured. (#15599)
|
||||
- Discord: avoid misrouting numeric guild allowlist entries to `/channels/<guildId>` by prefixing guild-only inputs with `guild:` during resolution. (#12326) Thanks @headswim.
|
||||
- Memory/QMD: default `memory.qmd.searchMode` to `search` for faster CPU-only recall and always scope `search`/`vsearch` requests to managed collections (auto-falling back to `query` when required). (#16047) Thanks @togotago.
|
||||
- MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (`29:...`, `8:orgid:...`) while still rejecting placeholder patterns. (#15436) Thanks @hyojin.
|
||||
- Media: classify `text/*` MIME types as documents in media-kind routing so text attachments are no longer treated as unknown. (#12237) Thanks @arosstale.
|
||||
- Inbound/Web UI: preserve literal `\n` sequences when normalizing inbound text so Windows paths like `C:\\Work\\nxxx\\README.md` are not corrupted. (#11547) Thanks @mcaxtr.
|
||||
- TUI/Streaming: preserve richer streamed assistant text when final payload drops pre-tool-call text blocks, while keeping non-empty final payload authoritative for plain-text updates. (#15452) Thanks @TsekaLuk.
|
||||
- Providers/MiniMax: switch implicit MiniMax API-key provider from `openai-completions` to `anthropic-messages` with the correct Anthropic-compatible base URL, fixing `invalid role: developer (2013)` errors on MiniMax M2.5. (#15275) Thanks @lailoo.
|
||||
- Ollama/Agents: use resolved model/provider base URLs for native `/api/chat` streaming (including aliased providers), normalize `/v1` endpoints, and forward abort + `maxTokens` stream options for reliable cancellation and token caps. (#11853) Thanks @BrokenFinger98.
|
||||
- OpenAI Codex/Spark: implement end-to-end `gpt-5.3-codex-spark` support across fallback/thinking/model resolution and `models list` forward-compat visibility. (#14990, #15174) Thanks @L-U-C-K-Y, @loiie45e.
|
||||
- Agents/Codex: allow `gpt-5.3-codex-spark` in forward-compat fallback, live model filtering, and thinking presets, and fix model-picker recognition for spark. (#14990) Thanks @L-U-C-K-Y.
|
||||
- Models/Codex: resolve configured `openai-codex/gpt-5.3-codex-spark` through forward-compat fallback during `models list`, so it is not incorrectly tagged as missing when runtime resolution succeeds. (#15174) Thanks @loiie45e.
|
||||
- OpenAI Codex/Auth: bridge OpenClaw OAuth profiles into `pi` `auth.json` so model discovery and models-list registry resolution can use Codex OAuth credentials. (#15184) Thanks @loiie45e.
|
||||
- Auth/OpenAI Codex: share OAuth login handling across onboarding and `models auth login --provider openai-codex`, keep onboarding alive when OAuth fails, and surface a direct OAuth help note instead of terminating the wizard. (#15406, follow-up to #14552) Thanks @zhiluo20.
|
||||
- Onboarding/Providers: add vLLM as an onboarding provider with model discovery, auth profile wiring, and non-interactive auth-choice validation. (#12577) Thanks @gejifeng.
|
||||
- Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck.
|
||||
- Signal/Install: auto-install `signal-cli` via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary `Exec format error` failures on arm64/arm hosts. (#15443) Thanks @jogvan-k.
|
||||
- macOS Voice Wake: fix a crash in trigger trimming for CJK/Unicode transcripts by matching and slicing on original-string ranges instead of transformed-string indices. (#11052) Thanks @Flash-LHR.
|
||||
- Mattermost (plugin): retry websocket monitor connections with exponential backoff and abort-aware teardown so transient connect failures no longer permanently stop monitoring. (#14962) Thanks @mcaxtr.
|
||||
- Discord/Agents: apply channel/group `historyLimit` during embedded-runner history compaction to prevent long-running channel sessions from bypassing truncation and overflowing context windows. (#11224) Thanks @shadril238.
|
||||
- Outbound targets: fail closed for WhatsApp/Twitch/Google Chat fallback paths so invalid or missing targets are dropped instead of rerouted, and align resolver hints with strict target requirements. (#13578) Thanks @mcaxtr.
|
||||
- Gateway/Restart: clear stale command-queue and heartbeat wake runtime state after SIGUSR1 in-process restarts to prevent zombie gateway behavior where queued work stops draining. (#15195) Thanks @joeykrug.
|
||||
- Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug.
|
||||
- Heartbeat: allow explicit wake (`wake`) and hook wake (`hook:*`) reasons to run even when `HEARTBEAT.md` is effectively empty so queued system events are processed. (#14527) Thanks @arosstale.
|
||||
- Auto-reply/Heartbeat: strip sentence-ending `HEARTBEAT_OK` tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish.
|
||||
- Agents/Heartbeat: stop auto-creating `HEARTBEAT.md` during workspace bootstrap so missing files continue to run heartbeat as documented. (#11766) Thanks @shadril238.
|
||||
- Sessions/Agents: pass `agentId` when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with `Session file path must be within sessions directory`. (#15141) Thanks @Goldenmonstew.
|
||||
- Sessions/Agents: pass `agentId` through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman.
|
||||
- Sessions: archive previous transcript files on `/new` and `/reset` session resets (including gateway `sessions.reset`) so stale transcripts do not accumulate on disk. (#14869) Thanks @mcaxtr.
|
||||
- Status/Sessions: stop clamping derived `totalTokens` to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic.
|
||||
- Gateway/Routing: speed up hot paths for session listing (derived titles + previews), WS broadcast, and binding resolution.
|
||||
- CLI/Completion: route plugin-load logs to stderr and write generated completion scripts directly to stdout to avoid `source <(openclaw completion ...)` corruption. (#15481) Thanks @arosstale.
|
||||
- CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle.
|
||||
- CLI: speed up startup by lazily registering core commands (keeps rich `--help` while reducing cold-start overhead).
|
||||
- Security/Gateway + ACP: block high-risk tools (`sessions_spawn`, `sessions_send`, `gateway`, `whatsapp_login`) from HTTP `/tools/invoke` by default with `gateway.tools.{allow,deny}` overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting `allow_always`/`reject_always`. (#15390) Thanks @aether-ai-agent.
|
||||
- Security/ACP: prompt for non-read/search permission requests in ACP clients (reduces silent tool approval risk). Thanks @aether-ai-agent.
|
||||
- Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo.
|
||||
- Security/Link understanding: block loopback/internal host patterns and private/mapped IPv6 addresses in extracted URL handling to close SSRF bypasses in link CLI flows. (#15604) Thanks @AI-Reviewer-QS.
|
||||
- Security/Browser: constrain `POST /trace/stop`, `POST /wait/download`, and `POST /download` output paths to OpenClaw temp roots and reject traversal/escape paths.
|
||||
- Security/Browser: sanitize download `suggestedFilename` to keep implicit `wait/download` paths within the downloads root. Thanks @1seal.
|
||||
- Security/Browser: confine `POST /hooks/file-chooser` upload paths to an OpenClaw temp uploads root and reject traversal/escape paths. Thanks @1seal.
|
||||
- Security/Canvas: serve A2UI assets via the shared safe-open path (`openFileWithinRoot`) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane.
|
||||
- Security/WhatsApp: enforce `0o600` on `creds.json` and `creds.json.bak` on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane.
|
||||
- Security/Gateway: sanitize and truncate untrusted WebSocket header values in pre-handshake close logs to reduce log-poisoning risk. Thanks @thewilloftheshadow.
|
||||
- Security/Audit: add misconfiguration checks for sandbox Docker config with sandbox mode off, ineffective `gateway.nodes.denyCommands` entries, global minimal tool-profile overrides by agent profiles, and permissive extension-plugin tool reachability.
|
||||
- Security/Audit: distinguish external webhooks (`hooks.enabled`) from internal hooks (`hooks.internal.enabled`) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr.
|
||||
- Security/Onboarding: clarify multi-user DM isolation remediation with explicit `openclaw config set session.dmScope ...` commands in security audit, doctor security, and channel onboarding guidance. (#13129) Thanks @VintLin.
|
||||
- Security/Gateway: bind node `system.run` approval overrides to gateway exec-approval records (runId-bound), preventing approval-bypass via `node.invoke` param injection. Thanks @222n5.
|
||||
- Agents/Nodes: harden node exec approval decision handling in the `nodes` tool run path by failing closed on unexpected approval decisions, and add regression coverage for approval-required retry/deny/timeout flows. (#4726) Thanks @rmorse.
|
||||
- Android/Nodes: harden `app.update` by requiring HTTPS and gateway-host URL matching plus SHA-256 verification, stream URL camera downloads to disk with size guards to avoid memory spikes, and stop signing release builds with debug keys. (#13541) Thanks @smartprogrammer93.
|
||||
- Routing: enforce strict binding-scope matching across peer/guild/team/roles so peer-scoped Discord/Slack bindings no longer match unrelated guild/team contexts or fallback tiers. (#15274) Thanks @lailoo.
|
||||
- Exec/Allowlist: allow multiline heredoc bodies (`<<`, `<<-`) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr.
|
||||
- Config: preserve `${VAR}` env references when writing config files so `openclaw config set/apply/patch` does not persist secrets to disk. Thanks @thewilloftheshadow.
|
||||
- Config: remove a cross-request env-snapshot race in config writes by carrying read-time env context into write calls per request, preserving `${VAR}` refs safely under concurrent gateway config mutations. (#11560) Thanks @akoscz.
|
||||
- Config: log overwrite audit entries (path, backup target, and hash transition) whenever an existing config file is replaced, improving traceability for unexpected config clobbers.
|
||||
- Config: keep legacy audio transcription migration strict by rejecting non-string/unsafe command tokens while still migrating valid custom script executables. (#5042) Thanks @shayan919293.
|
||||
- Config: accept `$schema` key in config file so JSON Schema editor tooling works without validation errors. (#14998)
|
||||
- Gateway/Tools Invoke: sanitize `/tools/invoke` execution failures while preserving `400` for tool input errors and returning `500` for unexpected runtime failures, with regression coverage and docs updates. (#13185) Thanks @davidrudduck.
|
||||
- Gateway/Hooks: preserve `408` for hook request-body timeout responses while keeping bounded auth-failure cache eviction behavior, with timeout-status regression coverage. (#15848) Thanks @AI-Reviewer-QS.
|
||||
- Plugins/Hooks: fire `before_tool_call` hook exactly once per tool invocation in embedded runs by removing duplicate dispatch paths while preserving parameter mutation semantics. (#15635) Thanks @lailoo.
|
||||
- Agents/Transcript policy: sanitize OpenAI/Codex tool-call ids during transcript policy normalization to prevent invalid tool-call identifiers from propagating into session history. (#15279) Thanks @divisonofficer.
|
||||
- Agents/Image tool: cap image-analysis completion `maxTokens` by model capability (`min(4096, model.maxTokens)`) to avoid over-limit provider failures while still preventing truncation. (#11770) Thanks @detecti1.
|
||||
- Agents/Compaction: centralize exec default resolution in the shared tool factory so per-agent `tools.exec` overrides (host/security/ask/node and related defaults) persist across compaction retries. (#15833) Thanks @napetrov.
|
||||
- Gateway/Agents: stop injecting a phantom `main` agent into gateway agent listings when `agents.list` explicitly excludes it. (#11450) Thanks @arosstale.
|
||||
- Process/Exec: avoid shell execution for `.exe` commands on Windows so env overrides work reliably in `runCommandWithTimeout`. Thanks @thewilloftheshadow.
|
||||
- Daemon/Windows: preserve literal backslashes in `gateway.cmd` command parsing so drive and UNC paths are not corrupted in runtime checks and doctor entrypoint comparisons. (#15642) Thanks @arosstale.
|
||||
- Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u.
|
||||
- Sandbox: pass configured `sandbox.docker.env` variables to sandbox containers at `docker create` time. (#15138) Thanks @stevebot-alive.
|
||||
- Voice Call: route webhook runtime event handling through shared manager event logic so rejected inbound hangups are idempotent in production, with regression tests for duplicate reject events and provider-call-ID remapping parity. (#15892) Thanks @dcantu96.
|
||||
- Cron: add regression coverage for announce-mode isolated jobs so runs that already report `delivered: true` do not enqueue duplicate main-session relays, including delivery configs where `mode` is omitted and defaults to announce. (#15737) Thanks @brandonwise.
|
||||
- Cron: honor `deleteAfterRun` in isolated announce delivery by mapping it to subagent announce cleanup mode, so cron run sessions configured for deletion are removed after completion. (#15368) Thanks @arosstale.
|
||||
- Web tools/web_fetch: prefer `text/markdown` responses for Cloudflare Markdown for Agents, add `cf-markdown` extraction for markdown bodies, and redact fetched URLs in `x-markdown-tokens` debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42.
|
||||
- Tools/web_search: support `freshness` for the Perplexity provider by mapping `pd`/`pw`/`pm`/`py` to Perplexity `search_recency_filter` values and including freshness in the Perplexity cache key. (#15343) Thanks @echoVic.
|
||||
- Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner.
|
||||
- Memory: switch default local embedding model to the QAT `embeddinggemma-300m-qat-Q8_0` variant for better quality at the same footprint. (#15429) Thanks @azade-c.
|
||||
- Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck.
|
||||
- macOS Voice Wake: fix a crash in trigger trimming for CJK/Unicode transcripts by matching and slicing on original-string ranges instead of transformed-string indices. (#11052) Thanks @Flash-LHR.
|
||||
- Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug.
|
||||
- Outbound targets: fail closed for WhatsApp/Twitch/Google Chat fallback paths so invalid or missing targets are dropped instead of rerouted, and align resolver hints with strict target requirements. (#13578) Thanks @mcaxtr.
|
||||
- Exec/Allowlist: allow multiline heredoc bodies (`<<`, `<<-`) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr.
|
||||
- Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.
|
||||
- Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.
|
||||
- Sessions/Agents: pass `agentId` when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with `Session file path must be within sessions directory`. (#15141) Thanks @Goldenmonstew.
|
||||
|
||||
## 2026.2.12
|
||||
|
||||
@@ -144,7 +50,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway: drain active turns before restart to prevent message loss. (#13931) Thanks @0xRaini.
|
||||
- Gateway: auto-generate auth token during install to prevent launchd restart loops. (#13813) Thanks @cathrynlavery.
|
||||
- Gateway: prevent `undefined`/missing token in auth config. (#13809) Thanks @asklee-klawd.
|
||||
- Configure/Gateway: reject literal `"undefined"`/`"null"` token input and validate gateway password prompt values to avoid invalid password-mode configs. (#13767) Thanks @omair445.
|
||||
- Gateway: handle async `EPIPE` on stdout/stderr during shutdown. (#13414) Thanks @keshav55.
|
||||
- Gateway/Control UI: resolve missing dashboard assets when `openclaw` is installed globally via symlink-based Node managers (nvm/fnm/n/Homebrew). (#14919) Thanks @aynorica.
|
||||
- Cron: use requested `agentId` for isolated job auth resolution. (#13983) Thanks @0xRaini.
|
||||
@@ -152,7 +57,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron: pass `agentId` to `runHeartbeatOnce` for main-session jobs. (#14140) Thanks @ishikawa-pro.
|
||||
- Cron: re-arm timers when `onTimer` fires while a job is still executing. (#14233) Thanks @tomron87.
|
||||
- Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.
|
||||
- Cron: prevent duplicate announce-mode isolated cron deliveries, and keep main-session fallback active when best-effort structured delivery attempts fail to send any message. (#15739) Thanks @widingmarcus-cyber.
|
||||
- Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.
|
||||
- Cron: prevent one-shot `at` jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.
|
||||
- Heartbeat: prevent scheduler stalls on unexpected run errors and avoid immediate rerun loops after `requests-in-flight` skips. (#14901) Thanks @joeykrug.
|
||||
@@ -165,14 +69,12 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram: surface REACTION_INVALID as non-fatal warning. (#14340) Thanks @0xRaini.
|
||||
- BlueBubbles: fix webhook auth bypass via loopback proxy trust. (#13787) Thanks @coygeek.
|
||||
- Slack: change default replyToMode from "off" to "all". (#14364) Thanks @nm-de.
|
||||
- Slack: honor `limit` for `emoji-list` actions across core and extension adapters, with capped emoji-list responses in the Slack action handler. (#4293) Thanks @mcaxtr.
|
||||
- Slack: detect control commands when channel messages start with bot mention prefixes (for example, `@Bot /new`). (#14142) Thanks @beefiker.
|
||||
- Slack: include thread reply metadata in inbound message footer context (`thread_ts`, `parent_user_id`) while keeping top-level `thread_ts == ts` events unthreaded. (#14625) Thanks @bennewton999.
|
||||
- Signal: enforce E.164 validation for the Signal bot account prompt so mistyped numbers are caught early. (#15063) Thanks @Duartemartins.
|
||||
- Discord: process DM reactions instead of silently dropping them. (#10418) Thanks @mcaxtr.
|
||||
- Discord: treat Administrator as full permissions in channel permission checks. Thanks @thewilloftheshadow.
|
||||
- Discord: respect replyToMode in threads. (#11062) Thanks @cordx56.
|
||||
- Discord: add optional gateway proxy support for WebSocket connections via `channels.discord.proxy`. (#10400) Thanks @winter-loo, @thewilloftheshadow.
|
||||
- Browser: add Chrome launch flag `--disable-blink-features=AutomationControlled` to reduce `navigator.webdriver` automation detection issues on reCAPTCHA-protected sites. (#10735) Thanks @Milofax.
|
||||
- Heartbeat: filter noise-only system events so scheduled reminder notifications do not fire when cron runs carry only heartbeat markers. (#13317) Thanks @pvtclawn.
|
||||
- Signal: render mention placeholders as `@uuid`/`@phone` so mention gating and Clawdbot targeting work. (#2013) Thanks @alexgleason.
|
||||
@@ -181,7 +83,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Onboarding/Providers: update MiniMax API default/recommended models from M2.1 to M2.5, add M2.5/M2.5-Lightning model entries, and include `minimax-m2.5` in modern model filtering. (#14865) Thanks @adao-max.
|
||||
- Ollama: use configured `models.providers.ollama.baseUrl` for model discovery and normalize `/v1` endpoints to the native Ollama API root. (#14131) Thanks @shtse8.
|
||||
- Voice Call: pass Twilio stream auth token via `<Parameter>` instead of query string. (#14029) Thanks @mcwigglesmcgee.
|
||||
- Config/Models: allow full `models.providers.*.models[*].compat` keys used by `openai-completions` (`thinkingFormat`, `supportsStrictMode`, and streaming/tool-result compatibility flags) so valid provider overrides no longer fail strict config validation. (#11063) Thanks @ikari-pl.
|
||||
- Feishu: pass `Buffer` directly to the Feishu SDK upload APIs instead of `Readable.from(...)` to avoid form-data upload failures. (#10345) Thanks @youngerstyle.
|
||||
- Feishu: trigger mention-gated group handling only when the bot itself is mentioned (not just any mention). (#11088) Thanks @openperf.
|
||||
- Feishu: probe status uses the resolved account context for multi-account credential checks. (#11233) Thanks @onevcat.
|
||||
@@ -207,7 +108,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents: keep followup-runner session `totalTokens` aligned with post-compaction context by using last-call usage and shared token-accounting logic. (#14979) Thanks @shtse8.
|
||||
- Hooks/Plugins: wire 9 previously unwired plugin lifecycle hooks into core runtime paths (session, compaction, gateway, and outbound message hooks). (#14882) Thanks @shtse8.
|
||||
- Hooks/Tools: dispatch `before_tool_call` and `after_tool_call` hooks from both tool execution paths with rebased conflict fixes. (#15012) Thanks @Patrick-Barletta, @Takhoffman.
|
||||
- Hooks: replace loader `console.*` output with subsystem logger messages so hook loading errors/warnings route through standard logging. (#11029) Thanks @shadril238.
|
||||
- Discord: allow channel-edit to archive/lock threads and set auto-archive duration. (#5542) Thanks @stumct.
|
||||
- Discord tests: use a partial @buape/carbon mock in slash command coverage. (#13262) Thanks @arosstale.
|
||||
- Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik.
|
||||
@@ -240,8 +140,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Sessions: prune stale entries, cap session store size, rotate large stores, accept duration/size thresholds, default to warn-only maintenance, and prune cron run sessions after retention windows. (#13083) Thanks @skyfallsin, @Glucksberg, @gumadeiras.
|
||||
- CI: Implement pipeline and workflow order. Thanks @quotentiroler.
|
||||
- WhatsApp: preserve original filenames for inbound documents. (#12691) Thanks @akramcodez.
|
||||
- Feishu: enforce DM `dmPolicy`/pairing gating and sender allow checks for inbound DMs. (#14876) Thanks @coygeek.
|
||||
- Telegram: harden quote parsing; preserve quote context; avoid QUOTE_TEXT_INVALID; avoid nested reply quote misclassification. (#12156) Thanks @rybnikov.
|
||||
- Security/Telegram: breaking default-behavior change — standalone canvas host + Telegram webhook listeners now bind loopback (`127.0.0.1`) instead of `0.0.0.0`; set `channels.telegram.webhookHost` when external ingress is required. (#13184) Thanks @davidrudduck.
|
||||
- Telegram: recover proactive sends when stale topic thread IDs are used by retrying without `message_thread_id`. (#11620)
|
||||
- Discord: auto-create forum/media thread posts on send, with chunked follow-up replies and media handling for forum sends. (#12380) Thanks @magendary, @thewilloftheshadow.
|
||||
- Discord: cap gateway reconnect attempts to avoid infinite retry loops. (#12230) Thanks @Yida-Dev.
|
||||
@@ -293,10 +193,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Memory/QMD: add `memory.qmd.searchMode` to choose `query`, `search`, or `vsearch` recall mode. (#9967, #10084)
|
||||
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
|
||||
- State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy.
|
||||
- Doctor/State dir: suppress repeated legacy migration warnings only for valid symlink mirrors, while keeping warnings for empty or invalid legacy trees. (#11709) Thanks @gumadeiras.
|
||||
- Tests: harden flaky hotspots by removing timer sleeps, consolidating onboarding provider-auth coverage, and improving memory test realism. (#11598) Thanks @gumadeiras.
|
||||
- macOS: honor Nix-managed defaults suite (`ai.openclaw.mac`) for nixMode to prevent onboarding from reappearing after bundle-id churn. (#12205) Thanks @joshp123.
|
||||
- Matrix: add multi-account support via `channels.matrix.accounts`; use per-account config for dm policy, allowFrom, groups, and other settings; serialize account startup to avoid race condition. (#7286, #3165, #3085) Thanks @emonty.
|
||||
|
||||
## 2026.2.6
|
||||
|
||||
@@ -323,7 +219,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- TTS: add missing OpenAI voices (ballad, cedar, juniper, marin, verse) to the allowlist so they are recognized instead of silently falling back to Edge TTS. (#2393)
|
||||
- Cron: scheduler reliability (timer drift, restart catch-up, lock contention, stale running markers). (#10776) Thanks @tyler6204.
|
||||
- Cron: store migration hardening (legacy field migration, parse error handling, explicit delivery mode persistence). (#10776) Thanks @tyler6204.
|
||||
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
|
||||
@@ -360,18 +255,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Control UI: add hardened fallback for asset resolution in global npm installs. (#4855) Thanks @anapivirtua.
|
||||
- Update: remove dead restore control-ui step that failed on gitignored dist/ output.
|
||||
- Update: avoid wiping prebuilt Control UI assets during dev auto-builds (`tsdown --no-clean`), run update doctor via `openclaw.mjs`, and auto-restore missing UI assets after doctor. (#10146) Thanks @gumadeiras.
|
||||
- Models: add forward-compat fallback for `openai-codex/gpt-5.3-codex` when model registry hasn't discovered it yet. (#9989) Thanks @w1kke.
|
||||
- Auto-reply/Docs: normalize `extra-high` (and spaced variants) to `xhigh` for Codex thinking levels, and align Codex 5.3 FAQ examples. (#9976) Thanks @slonce70.
|
||||
- Compaction: remove orphaned `tool_result` messages during history pruning to prevent session corruption from aborted tool calls. (#9868, fixes #9769, #9724, #9672)
|
||||
- Telegram: pass `parentPeer` for forum topic binding inheritance so group-level bindings apply to all topics within the group. (#9789, fixes #9545, #9351)
|
||||
- CLI: pass `--disable-warning=ExperimentalWarning` as a Node CLI option when respawning (avoid disallowed `NODE_OPTIONS` usage; fixes npm pack). (#9691) Thanks @18-RAJAT.
|
||||
- CLI: resolve bundled Chrome extension assets by walking up to the nearest assets directory; add resolver and clipboard tests. (#8914) Thanks @kelvinCB.
|
||||
- Tests: stabilize Windows ACL coverage with deterministic os.userInfo mocking. (#9335) Thanks @M00N7682.
|
||||
- Exec approvals: coerce bare string allowlist entries to objects to prevent allowlist corruption. (#9903, fixes #9790) Thanks @mcaxtr.
|
||||
- Exec approvals: ensure two-phase approval registration/decision flow works reliably by validating `twoPhase` requests and exposing `waitDecision` as an approvals-scoped gateway method. (#3357, fixes #2402) Thanks @ramin-shirali.
|
||||
- Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411.
|
||||
- TUI/Gateway: handle non-streaming finals, refresh history for non-local chat runs, and avoid event gap warnings for targeted tool streams. (#8432) Thanks @gumadeiras.
|
||||
- Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard.
|
||||
@@ -445,9 +328,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Security: require validated shared-secret auth before skipping device identity on gateway connect.
|
||||
- Security: guard skill installer downloads with SSRF checks (block private/localhost URLs).
|
||||
- Security: harden Windows exec allowlist; block cmd.exe bypass via single &. Thanks @simecek.
|
||||
- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
|
||||
- Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly.
|
||||
- fix(voice-call): harden inbound allowlist; reject anonymous callers; require Telnyx publicKey for allowlist; token-gate Twilio media streams; cap webhook body size (thanks @simecek)
|
||||
- Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly.
|
||||
- fix(webchat): respect user scroll position during streaming and refresh (#7226) (thanks @marcomarandiz)
|
||||
- Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23.
|
||||
- Agents: repair malformed tool calls and session transcripts. (#7473) Thanks @justinhuangcode.
|
||||
|
||||
@@ -53,13 +53,7 @@ For threat model + hardening guidance (including `openclaw security audit --deep
|
||||
|
||||
### Web Interface Safety
|
||||
|
||||
OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for **local use only**.
|
||||
|
||||
- Recommended: keep the Gateway **loopback-only** (`127.0.0.1` / `::1`).
|
||||
- Config: `gateway.bind="loopback"` (default).
|
||||
- CLI: `openclaw gateway run --bind loopback`.
|
||||
- Do **not** expose it to the public internet (no direct bind to `0.0.0.0`, no public reverse proxy). It is not hardened for public exposure.
|
||||
- If you need remote access, prefer an SSH tunnel or Tailscale serve/funnel (so the Gateway still binds to loopback), plus strong Gateway auth.
|
||||
OpenClaw's web interface is intended for local use only. Do **not** bind it to the public internet; it is not hardened for public exposure.
|
||||
|
||||
## Runtime Requirements
|
||||
|
||||
|
||||
151
appcast.xml
151
appcast.xml
@@ -2,107 +2,6 @@
|
||||
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
|
||||
<channel>
|
||||
<title>OpenClaw</title>
|
||||
<item>
|
||||
<title>2026.2.13</title>
|
||||
<pubDate>Sat, 14 Feb 2026 04:30:23 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>9846</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.13</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.13</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Discord: send voice messages with waveform previews from local audio files (including silent delivery). (#7253) Thanks @nyanjou.</li>
|
||||
<li>Discord: add configurable presence status/activity/type/url (custom status defaults to activity text). (#10855) Thanks @h0tp-ftw.</li>
|
||||
<li>Slack/Plugins: add thread-ownership outbound gating via <code>message_sending</code> hooks, including @-mention bypass tracking and Slack outbound hook wiring for cancel/modify behavior. (#15775) Thanks @DarlingtonDeveloper.</li>
|
||||
<li>Agents: add synthetic catalog support for <code>hf:zai-org/GLM-5</code>. (#15867) Thanks @battman21.</li>
|
||||
<li>Skills: remove duplicate <code>local-places</code> Google Places skill/proxy and keep <code>goplaces</code> as the single supported Google Places path.</li>
|
||||
<li>Agents: add pre-prompt context diagnostics (<code>messages</code>, <code>systemPromptChars</code>, <code>promptChars</code>, provider/model, session file) before embedded runner prompt calls to improve overflow debugging. (#8930) Thanks @Glucksberg.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.</li>
|
||||
<li>Auto-reply/Threading: auto-inject implicit reply threading so <code>replyToMode</code> works without requiring model-emitted <code>[[reply_to_current]]</code>, while preserving <code>replyToMode: "off"</code> behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under <code>replyToMode: "first"</code>. (#14976) Thanks @Diaspar4u.</li>
|
||||
<li>Outbound/Threading: pass <code>replyTo</code> and <code>threadId</code> from <code>message send</code> tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.</li>
|
||||
<li>Auto-reply/Media: allow image-only inbound messages (no caption) to reach the agent instead of short-circuiting as empty text, and preserve thread context in queued/followup prompt bodies for media-only runs. (#11916) Thanks @arosstale.</li>
|
||||
<li>Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.</li>
|
||||
<li>Web UI: add <code>img</code> to DOMPurify allowed tags and <code>src</code>/<code>alt</code> to allowed attributes so markdown images render in webchat instead of being stripped. (#15437) Thanks @lailoo.</li>
|
||||
<li>Telegram/Matrix: treat MP3 and M4A (including <code>audio/mp4</code>) as voice-compatible for <code>asVoice</code> routing, and keep WAV/AAC falling back to regular audio sends. (#15438) Thanks @azade-c.</li>
|
||||
<li>WhatsApp: preserve outbound document filenames for web-session document sends instead of always sending <code>"file"</code>. (#15594) Thanks @TsekaLuk.</li>
|
||||
<li>Telegram: cap bot menu registration to Telegram's 100-command limit with an overflow warning while keeping typed hidden commands available. (#15844) Thanks @battman21.</li>
|
||||
<li>Telegram: scope skill commands to the resolved agent for default accounts so <code>setMyCommands</code> no longer triggers <code>BOT_COMMANDS_TOO_MUCH</code> when multiple agents are configured. (#15599)</li>
|
||||
<li>Discord: avoid misrouting numeric guild allowlist entries to <code>/channels/<guildId></code> by prefixing guild-only inputs with <code>guild:</code> during resolution. (#12326) Thanks @headswim.</li>
|
||||
<li>MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (<code>29:...</code>, <code>8:orgid:...</code>) while still rejecting placeholder patterns. (#15436) Thanks @hyojin.</li>
|
||||
<li>Media: classify <code>text/*</code> MIME types as documents in media-kind routing so text attachments are no longer treated as unknown. (#12237) Thanks @arosstale.</li>
|
||||
<li>Inbound/Web UI: preserve literal <code>\n</code> sequences when normalizing inbound text so Windows paths like <code>C:\\Work\\nxxx\\README.md</code> are not corrupted. (#11547) Thanks @mcaxtr.</li>
|
||||
<li>TUI/Streaming: preserve richer streamed assistant text when final payload drops pre-tool-call text blocks, while keeping non-empty final payload authoritative for plain-text updates. (#15452) Thanks @TsekaLuk.</li>
|
||||
<li>Providers/MiniMax: switch implicit MiniMax API-key provider from <code>openai-completions</code> to <code>anthropic-messages</code> with the correct Anthropic-compatible base URL, fixing <code>invalid role: developer (2013)</code> errors on MiniMax M2.5. (#15275) Thanks @lailoo.</li>
|
||||
<li>Ollama/Agents: use resolved model/provider base URLs for native <code>/api/chat</code> streaming (including aliased providers), normalize <code>/v1</code> endpoints, and forward abort + <code>maxTokens</code> stream options for reliable cancellation and token caps. (#11853) Thanks @BrokenFinger98.</li>
|
||||
<li>OpenAI Codex/Spark: implement end-to-end <code>gpt-5.3-codex-spark</code> support across fallback/thinking/model resolution and <code>models list</code> forward-compat visibility. (#14990, #15174) Thanks @L-U-C-K-Y, @loiie45e.</li>
|
||||
<li>Agents/Codex: allow <code>gpt-5.3-codex-spark</code> in forward-compat fallback, live model filtering, and thinking presets, and fix model-picker recognition for spark. (#14990) Thanks @L-U-C-K-Y.</li>
|
||||
<li>Models/Codex: resolve configured <code>openai-codex/gpt-5.3-codex-spark</code> through forward-compat fallback during <code>models list</code>, so it is not incorrectly tagged as missing when runtime resolution succeeds. (#15174) Thanks @loiie45e.</li>
|
||||
<li>OpenAI Codex/Auth: bridge OpenClaw OAuth profiles into <code>pi</code> <code>auth.json</code> so model discovery and models-list registry resolution can use Codex OAuth credentials. (#15184) Thanks @loiie45e.</li>
|
||||
<li>Auth/OpenAI Codex: share OAuth login handling across onboarding and <code>models auth login --provider openai-codex</code>, keep onboarding alive when OAuth fails, and surface a direct OAuth help note instead of terminating the wizard. (#15406, follow-up to #14552) Thanks @zhiluo20.</li>
|
||||
<li>Onboarding/Providers: add vLLM as an onboarding provider with model discovery, auth profile wiring, and non-interactive auth-choice validation. (#12577) Thanks @gejifeng.</li>
|
||||
<li>Onboarding/Providers: preserve Hugging Face auth intent in auth-choice remapping (<code>tokenProvider=huggingface</code> with <code>authChoice=apiKey</code>) and skip env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp.</li>
|
||||
<li>Onboarding/CLI: restore terminal state without resuming paused <code>stdin</code>, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck.</li>
|
||||
<li>Signal/Install: auto-install <code>signal-cli</code> via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary <code>Exec format error</code> failures on arm64/arm hosts. (#15443) Thanks @jogvan-k.</li>
|
||||
<li>macOS Voice Wake: fix a crash in trigger trimming for CJK/Unicode transcripts by matching and slicing on original-string ranges instead of transformed-string indices. (#11052) Thanks @Flash-LHR.</li>
|
||||
<li>Mattermost (plugin): retry websocket monitor connections with exponential backoff and abort-aware teardown so transient connect failures no longer permanently stop monitoring. (#14962) Thanks @mcaxtr.</li>
|
||||
<li>Discord/Agents: apply channel/group <code>historyLimit</code> during embedded-runner history compaction to prevent long-running channel sessions from bypassing truncation and overflowing context windows. (#11224) Thanks @shadril238.</li>
|
||||
<li>Outbound targets: fail closed for WhatsApp/Twitch/Google Chat fallback paths so invalid or missing targets are dropped instead of rerouted, and align resolver hints with strict target requirements. (#13578) Thanks @mcaxtr.</li>
|
||||
<li>Gateway/Restart: clear stale command-queue and heartbeat wake runtime state after SIGUSR1 in-process restarts to prevent zombie gateway behavior where queued work stops draining. (#15195) Thanks @joeykrug.</li>
|
||||
<li>Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug.</li>
|
||||
<li>Heartbeat: allow explicit wake (<code>wake</code>) and hook wake (<code>hook:*</code>) reasons to run even when <code>HEARTBEAT.md</code> is effectively empty so queued system events are processed. (#14527) Thanks @arosstale.</li>
|
||||
<li>Auto-reply/Heartbeat: strip sentence-ending <code>HEARTBEAT_OK</code> tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish.</li>
|
||||
<li>Agents/Heartbeat: stop auto-creating <code>HEARTBEAT.md</code> during workspace bootstrap so missing files continue to run heartbeat as documented. (#11766) Thanks @shadril238.</li>
|
||||
<li>Sessions/Agents: pass <code>agentId</code> when resolving existing transcript paths in reply runs so non-default agents and heartbeat/chat handlers no longer fail with <code>Session file path must be within sessions directory</code>. (#15141) Thanks @Goldenmonstew.</li>
|
||||
<li>Sessions/Agents: pass <code>agentId</code> through status and usage transcript-resolution paths (auto-reply, gateway usage APIs, and session cost/log loaders) so non-default agents can resolve absolute session files without path-validation failures. (#15103) Thanks @jalehman.</li>
|
||||
<li>Sessions: archive previous transcript files on <code>/new</code> and <code>/reset</code> session resets (including gateway <code>sessions.reset</code>) so stale transcripts do not accumulate on disk. (#14869) Thanks @mcaxtr.</li>
|
||||
<li>Status/Sessions: stop clamping derived <code>totalTokens</code> to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic.</li>
|
||||
<li>CLI/Completion: route plugin-load logs to stderr and write generated completion scripts directly to stdout to avoid <code>source <(openclaw completion ...)</code> corruption. (#15481) Thanks @arosstale.</li>
|
||||
<li>CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle.</li>
|
||||
<li>Security/Gateway + ACP: block high-risk tools (<code>sessions_spawn</code>, <code>sessions_send</code>, <code>gateway</code>, <code>whatsapp_login</code>) from HTTP <code>/tools/invoke</code> by default with <code>gateway.tools.{allow,deny}</code> overrides, and harden ACP permission selection to fail closed when tool identity/options are ambiguous while supporting <code>allow_always</code>/<code>reject_always</code>. (#15390) Thanks @aether-ai-agent.</li>
|
||||
<li>Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo.</li>
|
||||
<li>Security/Link understanding: block loopback/internal host patterns and private/mapped IPv6 addresses in extracted URL handling to close SSRF bypasses in link CLI flows. (#15604) Thanks @AI-Reviewer-QS.</li>
|
||||
<li>Security/Browser: constrain <code>POST /trace/stop</code>, <code>POST /wait/download</code>, and <code>POST /download</code> output paths to OpenClaw temp roots and reject traversal/escape paths.</li>
|
||||
<li>Security/Canvas: serve A2UI assets via the shared safe-open path (<code>openFileWithinRoot</code>) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane.</li>
|
||||
<li>Security/WhatsApp: enforce <code>0o600</code> on <code>creds.json</code> and <code>creds.json.bak</code> on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane.</li>
|
||||
<li>Security/Gateway: sanitize and truncate untrusted WebSocket header values in pre-handshake close logs to reduce log-poisoning risk. Thanks @thewilloftheshadow.</li>
|
||||
<li>Security/Audit: add misconfiguration checks for sandbox Docker config with sandbox mode off, ineffective <code>gateway.nodes.denyCommands</code> entries, global minimal tool-profile overrides by agent profiles, and permissive extension-plugin tool reachability.</li>
|
||||
<li>Security/Audit: distinguish external webhooks (<code>hooks.enabled</code>) from internal hooks (<code>hooks.internal.enabled</code>) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr.</li>
|
||||
<li>Security/Onboarding: clarify multi-user DM isolation remediation with explicit <code>openclaw config set session.dmScope ...</code> commands in security audit, doctor security, and channel onboarding guidance. (#13129) Thanks @VintLin.</li>
|
||||
<li>Agents/Nodes: harden node exec approval decision handling in the <code>nodes</code> tool run path by failing closed on unexpected approval decisions, and add regression coverage for approval-required retry/deny/timeout flows. (#4726) Thanks @rmorse.</li>
|
||||
<li>Android/Nodes: harden <code>app.update</code> by requiring HTTPS and gateway-host URL matching plus SHA-256 verification, stream URL camera downloads to disk with size guards to avoid memory spikes, and stop signing release builds with debug keys. (#13541) Thanks @smartprogrammer93.</li>
|
||||
<li>Routing: enforce strict binding-scope matching across peer/guild/team/roles so peer-scoped Discord/Slack bindings no longer match unrelated guild/team contexts or fallback tiers. (#15274) Thanks @lailoo.</li>
|
||||
<li>Exec/Allowlist: allow multiline heredoc bodies (<code><<</code>, <code><<-</code>) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr.</li>
|
||||
<li>Config: preserve <code>${VAR}</code> env references when writing config files so <code>openclaw config set/apply/patch</code> does not persist secrets to disk. Thanks @thewilloftheshadow.</li>
|
||||
<li>Config: remove a cross-request env-snapshot race in config writes by carrying read-time env context into write calls per request, preserving <code>${VAR}</code> refs safely under concurrent gateway config mutations. (#11560) Thanks @akoscz.</li>
|
||||
<li>Config: log overwrite audit entries (path, backup target, and hash transition) whenever an existing config file is replaced, improving traceability for unexpected config clobbers.</li>
|
||||
<li>Config: keep legacy audio transcription migration strict by rejecting non-string/unsafe command tokens while still migrating valid custom script executables. (#5042) Thanks @shayan919293.</li>
|
||||
<li>Config: accept <code>$schema</code> key in config file so JSON Schema editor tooling works without validation errors. (#14998)</li>
|
||||
<li>Gateway/Tools Invoke: sanitize <code>/tools/invoke</code> execution failures while preserving <code>400</code> for tool input errors and returning <code>500</code> for unexpected runtime failures, with regression coverage and docs updates. (#13185) Thanks @davidrudduck.</li>
|
||||
<li>Gateway/Hooks: preserve <code>408</code> for hook request-body timeout responses while keeping bounded auth-failure cache eviction behavior, with timeout-status regression coverage. (#15848) Thanks @AI-Reviewer-QS.</li>
|
||||
<li>Plugins/Hooks: fire <code>before_tool_call</code> hook exactly once per tool invocation in embedded runs by removing duplicate dispatch paths while preserving parameter mutation semantics. (#15635) Thanks @lailoo.</li>
|
||||
<li>Agents/Transcript policy: sanitize OpenAI/Codex tool-call ids during transcript policy normalization to prevent invalid tool-call identifiers from propagating into session history. (#15279) Thanks @divisonofficer.</li>
|
||||
<li>Agents/Image tool: cap image-analysis completion <code>maxTokens</code> by model capability (<code>min(4096, model.maxTokens)</code>) to avoid over-limit provider failures while still preventing truncation. (#11770) Thanks @detecti1.</li>
|
||||
<li>Agents/Compaction: centralize exec default resolution in the shared tool factory so per-agent <code>tools.exec</code> overrides (host/security/ask/node and related defaults) persist across compaction retries. (#15833) Thanks @napetrov.</li>
|
||||
<li>Gateway/Agents: stop injecting a phantom <code>main</code> agent into gateway agent listings when <code>agents.list</code> explicitly excludes it. (#11450) Thanks @arosstale.</li>
|
||||
<li>Process/Exec: avoid shell execution for <code>.exe</code> commands on Windows so env overrides work reliably in <code>runCommandWithTimeout</code>. Thanks @thewilloftheshadow.</li>
|
||||
<li>Daemon/Windows: preserve literal backslashes in <code>gateway.cmd</code> command parsing so drive and UNC paths are not corrupted in runtime checks and doctor entrypoint comparisons. (#15642) Thanks @arosstale.</li>
|
||||
<li>Sandbox: pass configured <code>sandbox.docker.env</code> variables to sandbox containers at <code>docker create</code> time. (#15138) Thanks @stevebot-alive.</li>
|
||||
<li>Voice Call: route webhook runtime event handling through shared manager event logic so rejected inbound hangups are idempotent in production, with regression tests for duplicate reject events and provider-call-ID remapping parity. (#15892) Thanks @dcantu96.</li>
|
||||
<li>Cron: add regression coverage for announce-mode isolated jobs so runs that already report <code>delivered: true</code> do not enqueue duplicate main-session relays, including delivery configs where <code>mode</code> is omitted and defaults to announce. (#15737) Thanks @brandonwise.</li>
|
||||
<li>Cron: honor <code>deleteAfterRun</code> in isolated announce delivery by mapping it to subagent announce cleanup mode, so cron run sessions configured for deletion are removed after completion. (#15368) Thanks @arosstale.</li>
|
||||
<li>Web tools/web_fetch: prefer <code>text/markdown</code> responses for Cloudflare Markdown for Agents, add <code>cf-markdown</code> extraction for markdown bodies, and redact fetched URLs in <code>x-markdown-tokens</code> debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42.</li>
|
||||
<li>Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner.</li>
|
||||
<li>Memory: switch default local embedding model to the QAT <code>embeddinggemma-300m-qat-Q8_0</code> variant for better quality at the same footprint. (#15429) Thanks @azade-c.</li>
|
||||
<li>Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.13/OpenClaw-2026.2.13.zip" length="22902077" type="application/octet-stream" sparkle:edSignature="RpkwlPtB2yN7UOYZWfthV5grhDUcbhcHMeicdRA864Vo/P0Hnq5aHKmSvcbWkjHut96TC57bX+AeUrL7txpLCg=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.12</title>
|
||||
<pubDate>Fri, 13 Feb 2026 03:17:54 +0100</pubDate>
|
||||
@@ -255,5 +154,55 @@
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.9/OpenClaw-2026.2.9.zip" length="22872529" type="application/octet-stream" sparkle:edSignature="zvgwqlgqI7J5Gsi9VSULIQTMKqLiGE5ulC6NnRLKtOPphQsHZVdYSWm0E90+Yq8mG4lpsvbxQOSSPxpl43QTAw=="/>
|
||||
</item>
|
||||
<item>
|
||||
<title>2026.2.3</title>
|
||||
<pubDate>Wed, 04 Feb 2026 17:47:10 -0800</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>8900</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.3</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.3</h2>
|
||||
<h3>Changes</h3>
|
||||
<ul>
|
||||
<li>Telegram: remove last <code>@ts-nocheck</code> from <code>bot-handlers.ts</code>, use Grammy types directly, deduplicate <code>StickerMetadata</code>. Zero <code>@ts-nocheck</code> remaining in <code>src/telegram/</code>. (#9206)</li>
|
||||
<li>Telegram: remove <code>@ts-nocheck</code> from <code>bot-message.ts</code>, type deps via <code>Omit<BuildTelegramMessageContextParams></code>, widen <code>allMedia</code> to <code>TelegramMediaRef[]</code>. (#9180)</li>
|
||||
<li>Telegram: remove <code>@ts-nocheck</code> from <code>bot.ts</code>, fix duplicate <code>bot.catch</code> error handler (Grammy overrides), remove dead reaction <code>message_thread_id</code> routing, harden sticker cache guard. (#9077)</li>
|
||||
<li>Onboarding: add Cloudflare AI Gateway provider setup and docs. (#7914) Thanks @roerohan.</li>
|
||||
<li>Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz.</li>
|
||||
<li>Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov.</li>
|
||||
<li>Docs: mirror the landing page revamp for zh-CN (features, quickstart, docs directory, network model, credits). (#8994) Thanks @joshp123.</li>
|
||||
<li>Messages: add per-channel and per-account responsePrefix overrides across channels. (#9001) Thanks @mudrii.</li>
|
||||
<li>Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config.</li>
|
||||
<li>Cron: default isolated jobs to announce delivery; accept ISO 8601 <code>schedule.at</code> in tool inputs.</li>
|
||||
<li>Cron: hard-migrate isolated jobs to announce/none delivery; drop legacy post-to-main/payload delivery fields and <code>atMs</code> inputs.</li>
|
||||
<li>Cron: delete one-shot jobs after success by default; add <code>--keep-after-run</code> for CLI.</li>
|
||||
<li>Cron: suppress messaging tools during announce delivery so summaries post consistently.</li>
|
||||
<li>Cron: avoid duplicate deliveries when isolated runs send messages directly.</li>
|
||||
</ul>
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
<li>Heartbeat: allow explicit accountId routing for multi-account channels. (#8702) Thanks @lsh411.</li>
|
||||
<li>TUI/Gateway: handle non-streaming finals, refresh history for non-local chat runs, and avoid event gap warnings for targeted tool streams. (#8432) Thanks @gumadeiras.</li>
|
||||
<li>Shell completion: auto-detect and migrate slow dynamic patterns to cached files for faster terminal startup; add completion health checks to doctor/update/onboard.</li>
|
||||
<li>Telegram: honor session model overrides in inline model selection. (#8193) Thanks @gildo.</li>
|
||||
<li>Web UI: fix agent model selection saves for default/non-default agents and wrap long workspace paths. Thanks @Takhoffman.</li>
|
||||
<li>Web UI: resolve header logo path when <code>gateway.controlUi.basePath</code> is set. (#7178) Thanks @Yeom-JinHo.</li>
|
||||
<li>Web UI: apply button styling to the new-messages indicator.</li>
|
||||
<li>Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin.</li>
|
||||
<li>Security: enforce sandboxed media paths for message tool attachments. (#9182) Thanks @victormier.</li>
|
||||
<li>Security: require explicit credentials for gateway URL overrides to prevent credential leakage. (#8113) Thanks @victormier.</li>
|
||||
<li>Security: gate <code>whatsapp_login</code> tool to owner senders and default-deny non-owner contexts. (#8768) Thanks @victormier.</li>
|
||||
<li>Voice call: harden webhook verification with host allowlists/proxy trust and keep ngrok loopback bypass.</li>
|
||||
<li>Voice call: add regression coverage for anonymous inbound caller IDs with allowlist policy. (#8104) Thanks @victormier.</li>
|
||||
<li>Cron: accept epoch timestamps and 0ms durations in CLI <code>--at</code> parsing.</li>
|
||||
<li>Cron: reload store data when the store file is recreated or mtime changes.</li>
|
||||
<li>Cron: deliver announce runs directly, honor delivery mode, and respect wakeMode for summaries. (#8540) Thanks @tyler6204.</li>
|
||||
<li>Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg.</li>
|
||||
<li>macOS: fix cron payload summary rendering and ISO 8601 formatter concurrency safety.</li>
|
||||
</ul>
|
||||
<p><a href="https://github.com/openclaw/openclaw/blob/main/CHANGELOG.md">View full changelog</a></p>
|
||||
]]></description>
|
||||
<enclosure url="https://github.com/openclaw/openclaw/releases/download/v2026.2.3/OpenClaw-2026.2.3.zip" length="22530161" type="application/octet-stream" sparkle:edSignature="7eHUaQC6cx87HWbcaPh9T437+LqfE9VtQBf4p9JBjIyBrqGYxxp9KPvI5unEjg55j9j2djCXhseSMeyyRmvYBg=="/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -23,19 +23,10 @@ android {
|
||||
targetSdk = 36
|
||||
versionCode = 202602130
|
||||
versionName = "2026.2.13"
|
||||
ndk {
|
||||
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
|
||||
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
}
|
||||
}
|
||||
@@ -52,13 +43,7 @@ android {
|
||||
|
||||
packaging {
|
||||
resources {
|
||||
excludes += setOf(
|
||||
"/META-INF/{AL2.0,LGPL2.1}",
|
||||
"/META-INF/*.version",
|
||||
"/META-INF/LICENSE*.txt",
|
||||
"DebugProbesKt.bin",
|
||||
"kotlin-tooling-metadata.json",
|
||||
)
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,8 +90,6 @@ dependencies {
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
// material-icons-extended pulled in full icon set (~20 MB DEX). Only ~18 icons used.
|
||||
// R8 will tree-shake unused icons when minify is enabled on release builds.
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
implementation("androidx.navigation:navigation-compose:2.9.6")
|
||||
|
||||
@@ -121,7 +104,6 @@ dependencies {
|
||||
implementation("androidx.security:security-crypto:1.1.0")
|
||||
implementation("androidx.exifinterface:exifinterface:1.4.2")
|
||||
implementation("com.squareup.okhttp3:okhttp:5.3.2")
|
||||
implementation("org.bouncycastle:bcprov-jdk18on:1.83")
|
||||
|
||||
// CameraX (for node.invoke camera.* parity)
|
||||
implementation("androidx.camera:camera-core:1.5.2")
|
||||
|
||||
28
apps/android/app/proguard-rules.pro
vendored
28
apps/android/app/proguard-rules.pro
vendored
@@ -1,28 +0,0 @@
|
||||
# ── App classes ───────────────────────────────────────────────────
|
||||
-keep class ai.openclaw.android.** { *; }
|
||||
|
||||
# ── Bouncy Castle ─────────────────────────────────────────────────
|
||||
-keep class org.bouncycastle.** { *; }
|
||||
-dontwarn org.bouncycastle.**
|
||||
|
||||
# ── CameraX ───────────────────────────────────────────────────────
|
||||
-keep class androidx.camera.** { *; }
|
||||
|
||||
# ── kotlinx.serialization ────────────────────────────────────────
|
||||
-keep class kotlinx.serialization.** { *; }
|
||||
-keepclassmembers class * {
|
||||
@kotlinx.serialization.Serializable *;
|
||||
}
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
|
||||
# ── OkHttp ────────────────────────────────────────────────────────
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
-keep class okhttp3.internal.platform.** { *; }
|
||||
|
||||
# ── Misc suppressions ────────────────────────────────────────────
|
||||
-dontwarn com.sun.jna.**
|
||||
-dontwarn javax.naming.**
|
||||
-dontwarn lombok.Generated
|
||||
-dontwarn org.slf4j.impl.StaticLoggerBinder
|
||||
-dontwarn sun.net.spi.nameservice.NameServiceDescriptor
|
||||
@@ -15,7 +15,6 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.camera"
|
||||
android:required="false" />
|
||||
@@ -38,27 +37,13 @@
|
||||
android:name=".NodeForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync|microphone|mediaProjection" />
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|density|keyboard|keyboardHidden|navigation">
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
android:name=".InstallResultReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
package ai.openclaw.android
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.util.Log
|
||||
|
||||
class InstallResultReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
|
||||
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
|
||||
when (status) {
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
// System needs user confirmation — launch the confirmation activity
|
||||
@Suppress("DEPRECATION")
|
||||
val confirmIntent = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
|
||||
if (confirmIntent != null) {
|
||||
confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(confirmIntent)
|
||||
Log.w("openclaw", "app.update: user confirmation requested, launching install dialog")
|
||||
}
|
||||
}
|
||||
PackageInstaller.STATUS_SUCCESS -> {
|
||||
Log.w("openclaw", "app.update: install SUCCESS")
|
||||
}
|
||||
else -> {
|
||||
Log.e("openclaw", "app.update: install FAILED status=$status message=$message")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
val manualHost: StateFlow<String> = runtime.manualHost
|
||||
val manualPort: StateFlow<Int> = runtime.manualPort
|
||||
val manualTls: StateFlow<Boolean> = runtime.manualTls
|
||||
val gatewayToken: StateFlow<String> = runtime.gatewayToken
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = runtime.canvasDebugStatusEnabled
|
||||
|
||||
val chatSessionKey: StateFlow<String> = runtime.chatSessionKey
|
||||
@@ -105,10 +104,6 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
|
||||
runtime.setManualTls(value)
|
||||
}
|
||||
|
||||
fun setGatewayToken(value: String) {
|
||||
runtime.setGatewayToken(value)
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
runtime.setCanvasDebugStatusEnabled(value)
|
||||
}
|
||||
|
||||
@@ -2,23 +2,12 @@ package ai.openclaw.android
|
||||
|
||||
import android.app.Application
|
||||
import android.os.StrictMode
|
||||
import android.util.Log
|
||||
import java.security.Security
|
||||
|
||||
class NodeApp : Application() {
|
||||
val runtime: NodeRuntime by lazy { NodeRuntime(this) }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
// Register Bouncy Castle as highest-priority provider for Ed25519 support
|
||||
try {
|
||||
val bcProvider = Class.forName("org.bouncycastle.jce.provider.BouncyCastleProvider")
|
||||
.getDeclaredConstructor().newInstance() as java.security.Provider
|
||||
Security.removeProvider("BC")
|
||||
Security.insertProviderAt(bcProvider, 1)
|
||||
} catch (it: Throwable) {
|
||||
Log.e("NodeApp", "Failed to register Bouncy Castle provider", it)
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
StrictMode.setThreadPolicy(
|
||||
StrictMode.ThreadPolicy.Builder()
|
||||
|
||||
@@ -3,6 +3,8 @@ package ai.openclaw.android
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.android.chat.ChatController
|
||||
@@ -12,26 +14,45 @@ import ai.openclaw.android.chat.ChatSessionEntry
|
||||
import ai.openclaw.android.chat.OutgoingAttachment
|
||||
import ai.openclaw.android.gateway.DeviceAuthStore
|
||||
import ai.openclaw.android.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.android.gateway.GatewayClientInfo
|
||||
import ai.openclaw.android.gateway.GatewayConnectOptions
|
||||
import ai.openclaw.android.gateway.GatewayDiscovery
|
||||
import ai.openclaw.android.gateway.GatewayEndpoint
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import ai.openclaw.android.node.*
|
||||
import ai.openclaw.android.gateway.GatewayTlsParams
|
||||
import ai.openclaw.android.node.CameraCaptureManager
|
||||
import ai.openclaw.android.node.LocationCaptureManager
|
||||
import ai.openclaw.android.BuildConfig
|
||||
import ai.openclaw.android.node.CanvasController
|
||||
import ai.openclaw.android.node.ScreenRecordManager
|
||||
import ai.openclaw.android.node.SmsManager
|
||||
import ai.openclaw.android.protocol.OpenClawCapability
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UIAction
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
import ai.openclaw.android.voice.TalkModeManager
|
||||
import ai.openclaw.android.voice.VoiceWakeManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
@@ -91,80 +112,6 @@ class NodeRuntime(context: Context) {
|
||||
val discoveryStatusText: StateFlow<String> = discovery.statusText
|
||||
|
||||
private val identityStore = DeviceIdentityStore(appContext)
|
||||
private var connectedEndpoint: GatewayEndpoint? = null
|
||||
|
||||
private val cameraHandler: CameraHandler = CameraHandler(
|
||||
appContext = appContext,
|
||||
camera = camera,
|
||||
prefs = prefs,
|
||||
connectedEndpoint = { connectedEndpoint },
|
||||
externalAudioCaptureActive = externalAudioCaptureActive,
|
||||
showCameraHud = ::showCameraHud,
|
||||
triggerCameraFlash = ::triggerCameraFlash,
|
||||
invokeErrorFromThrowable = { invokeErrorFromThrowable(it) },
|
||||
)
|
||||
|
||||
private val debugHandler: DebugHandler = DebugHandler(
|
||||
appContext = appContext,
|
||||
identityStore = identityStore,
|
||||
)
|
||||
|
||||
private val appUpdateHandler: AppUpdateHandler = AppUpdateHandler(
|
||||
appContext = appContext,
|
||||
connectedEndpoint = { connectedEndpoint },
|
||||
)
|
||||
|
||||
private val locationHandler: LocationHandler = LocationHandler(
|
||||
appContext = appContext,
|
||||
location = location,
|
||||
json = json,
|
||||
isForeground = { _isForeground.value },
|
||||
locationMode = { locationMode.value },
|
||||
locationPreciseEnabled = { locationPreciseEnabled.value },
|
||||
)
|
||||
|
||||
private val screenHandler: ScreenHandler = ScreenHandler(
|
||||
screenRecorder = screenRecorder,
|
||||
setScreenRecordActive = { _screenRecordActive.value = it },
|
||||
invokeErrorFromThrowable = { invokeErrorFromThrowable(it) },
|
||||
)
|
||||
|
||||
private val smsHandlerImpl: SmsHandler = SmsHandler(
|
||||
sms = sms,
|
||||
)
|
||||
|
||||
private val a2uiHandler: A2UIHandler = A2UIHandler(
|
||||
canvas = canvas,
|
||||
json = json,
|
||||
getNodeCanvasHostUrl = { nodeSession.currentCanvasHostUrl() },
|
||||
getOperatorCanvasHostUrl = { operatorSession.currentCanvasHostUrl() },
|
||||
)
|
||||
|
||||
private val connectionManager: ConnectionManager = ConnectionManager(
|
||||
prefs = prefs,
|
||||
cameraEnabled = { cameraEnabled.value },
|
||||
locationMode = { locationMode.value },
|
||||
voiceWakeMode = { voiceWakeMode.value },
|
||||
smsAvailable = { sms.canSendSms() },
|
||||
hasRecordAudioPermission = { hasRecordAudioPermission() },
|
||||
manualTls = { manualTls.value },
|
||||
)
|
||||
|
||||
private val invokeDispatcher: InvokeDispatcher = InvokeDispatcher(
|
||||
canvas = canvas,
|
||||
cameraHandler = cameraHandler,
|
||||
locationHandler = locationHandler,
|
||||
screenHandler = screenHandler,
|
||||
smsHandler = smsHandlerImpl,
|
||||
a2uiHandler = a2uiHandler,
|
||||
debugHandler = debugHandler,
|
||||
appUpdateHandler = appUpdateHandler,
|
||||
isForeground = { _isForeground.value },
|
||||
cameraEnabled = { cameraEnabled.value },
|
||||
locationEnabled = { locationMode.value != LocationMode.Off },
|
||||
)
|
||||
|
||||
private lateinit var gatewayEventHandler: GatewayEventHandler
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
@@ -202,6 +149,7 @@ class NodeRuntime(context: Context) {
|
||||
private var nodeConnected = false
|
||||
private var operatorStatusText: String = "Offline"
|
||||
private var nodeStatusText: String = "Offline"
|
||||
private var connectedEndpoint: GatewayEndpoint? = null
|
||||
|
||||
private val operatorSession =
|
||||
GatewaySession(
|
||||
@@ -217,7 +165,7 @@ class NodeRuntime(context: Context) {
|
||||
applyMainSessionKey(mainSessionKey)
|
||||
updateStatus()
|
||||
scope.launch { refreshBrandingFromGateway() }
|
||||
scope.launch { gatewayEventHandler.refreshWakeWordsFromGateway() }
|
||||
scope.launch { refreshWakeWordsFromGateway() }
|
||||
},
|
||||
onDisconnected = { message ->
|
||||
operatorConnected = false
|
||||
@@ -258,7 +206,7 @@ class NodeRuntime(context: Context) {
|
||||
},
|
||||
onEvent = { _, _ -> },
|
||||
onInvoke = { req ->
|
||||
invokeDispatcher.handleInvoke(req.command, req.paramsJson)
|
||||
handleInvoke(req.command, req.paramsJson)
|
||||
},
|
||||
onTlsFingerprint = { stableId, fingerprint ->
|
||||
prefs.saveGatewayTlsFingerprint(stableId, fingerprint)
|
||||
@@ -283,7 +231,8 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
|
||||
private fun applyMainSessionKey(candidate: String?) {
|
||||
val trimmed = normalizeMainKey(candidate) ?: return
|
||||
val trimmed = candidate?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return
|
||||
if (isCanonicalMainSessionKey(_mainSessionKey.value)) return
|
||||
if (_mainSessionKey.value == trimmed) return
|
||||
_mainSessionKey.value = trimmed
|
||||
@@ -309,7 +258,7 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
|
||||
private fun maybeNavigateToA2uiOnConnect() {
|
||||
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl() ?: return
|
||||
val a2uiUrl = resolveA2uiHostUrl() ?: return
|
||||
val current = canvas.currentUrl()?.trim().orEmpty()
|
||||
if (current.isEmpty() || current == lastAutoA2uiUrl) {
|
||||
lastAutoA2uiUrl = a2uiUrl
|
||||
@@ -335,12 +284,12 @@ class NodeRuntime(context: Context) {
|
||||
val manualHost: StateFlow<String> = prefs.manualHost
|
||||
val manualPort: StateFlow<Int> = prefs.manualPort
|
||||
val manualTls: StateFlow<Boolean> = prefs.manualTls
|
||||
val gatewayToken: StateFlow<String> = prefs.gatewayToken
|
||||
fun setGatewayToken(value: String) = prefs.setGatewayToken(value)
|
||||
val lastDiscoveredStableId: StateFlow<String> = prefs.lastDiscoveredStableId
|
||||
val canvasDebugStatusEnabled: StateFlow<Boolean> = prefs.canvasDebugStatusEnabled
|
||||
|
||||
private var didAutoConnect = false
|
||||
private var suppressWakeWordsSync = false
|
||||
private var wakeWordsSyncJob: Job? = null
|
||||
|
||||
val chatSessionKey: StateFlow<String> = chat.sessionKey
|
||||
val chatSessionId: StateFlow<String?> = chat.sessionId
|
||||
@@ -354,14 +303,6 @@ class NodeRuntime(context: Context) {
|
||||
val pendingRunCount: StateFlow<Int> = chat.pendingRunCount
|
||||
|
||||
init {
|
||||
gatewayEventHandler = GatewayEventHandler(
|
||||
scope = scope,
|
||||
prefs = prefs,
|
||||
json = json,
|
||||
operatorSession = operatorSession,
|
||||
isConnected = { _isConnected.value },
|
||||
)
|
||||
|
||||
scope.launch {
|
||||
combine(
|
||||
voiceWakeMode,
|
||||
@@ -493,7 +434,7 @@ class NodeRuntime(context: Context) {
|
||||
|
||||
fun setWakeWords(words: List<String>) {
|
||||
prefs.setWakeWords(words)
|
||||
gatewayEventHandler.scheduleWakeWordsSyncIfNeeded()
|
||||
scheduleWakeWordsSyncIfNeeded()
|
||||
}
|
||||
|
||||
fun resetWakeWordsDefaults() {
|
||||
@@ -508,13 +449,110 @@ class NodeRuntime(context: Context) {
|
||||
prefs.setTalkEnabled(value)
|
||||
}
|
||||
|
||||
private fun buildInvokeCommands(): List<String> =
|
||||
buildList {
|
||||
add(OpenClawCanvasCommand.Present.rawValue)
|
||||
add(OpenClawCanvasCommand.Hide.rawValue)
|
||||
add(OpenClawCanvasCommand.Navigate.rawValue)
|
||||
add(OpenClawCanvasCommand.Eval.rawValue)
|
||||
add(OpenClawCanvasCommand.Snapshot.rawValue)
|
||||
add(OpenClawCanvasA2UICommand.Push.rawValue)
|
||||
add(OpenClawCanvasA2UICommand.PushJSONL.rawValue)
|
||||
add(OpenClawCanvasA2UICommand.Reset.rawValue)
|
||||
add(OpenClawScreenCommand.Record.rawValue)
|
||||
if (cameraEnabled.value) {
|
||||
add(OpenClawCameraCommand.Snap.rawValue)
|
||||
add(OpenClawCameraCommand.Clip.rawValue)
|
||||
}
|
||||
if (locationMode.value != LocationMode.Off) {
|
||||
add(OpenClawLocationCommand.Get.rawValue)
|
||||
}
|
||||
if (sms.canSendSms()) {
|
||||
add(OpenClawSmsCommand.Send.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCapabilities(): List<String> =
|
||||
buildList {
|
||||
add(OpenClawCapability.Canvas.rawValue)
|
||||
add(OpenClawCapability.Screen.rawValue)
|
||||
if (cameraEnabled.value) add(OpenClawCapability.Camera.rawValue)
|
||||
if (sms.canSendSms()) add(OpenClawCapability.Sms.rawValue)
|
||||
if (voiceWakeMode.value != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||
add(OpenClawCapability.VoiceWake.rawValue)
|
||||
}
|
||||
if (locationMode.value != LocationMode.Off) {
|
||||
add(OpenClawCapability.Location.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolvedVersionName(): String {
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
"$versionName-dev"
|
||||
} else {
|
||||
versionName
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveModelIdentifier(): String? {
|
||||
return listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
.joinToString(" ")
|
||||
.trim()
|
||||
.ifEmpty { null }
|
||||
}
|
||||
|
||||
private fun buildUserAgent(): String {
|
||||
val version = resolvedVersionName()
|
||||
val release = Build.VERSION.RELEASE?.trim().orEmpty()
|
||||
val releaseLabel = if (release.isEmpty()) "unknown" else release
|
||||
return "OpenClawAndroid/$version (Android $releaseLabel; SDK ${Build.VERSION.SDK_INT})"
|
||||
}
|
||||
|
||||
private fun buildClientInfo(clientId: String, clientMode: String): GatewayClientInfo {
|
||||
return GatewayClientInfo(
|
||||
id = clientId,
|
||||
displayName = displayName.value,
|
||||
version = resolvedVersionName(),
|
||||
platform = "android",
|
||||
mode = clientMode,
|
||||
instanceId = instanceId.value,
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = resolveModelIdentifier(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildNodeConnectOptions(): GatewayConnectOptions {
|
||||
return GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = emptyList(),
|
||||
caps = buildCapabilities(),
|
||||
commands = buildInvokeCommands(),
|
||||
permissions = emptyMap(),
|
||||
client = buildClientInfo(clientId = "openclaw-android", clientMode = "node"),
|
||||
userAgent = buildUserAgent(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildOperatorConnectOptions(): GatewayConnectOptions {
|
||||
return GatewayConnectOptions(
|
||||
role = "operator",
|
||||
scopes = emptyList(),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client = buildClientInfo(clientId = "openclaw-control-ui", clientMode = "ui"),
|
||||
userAgent = buildUserAgent(),
|
||||
)
|
||||
}
|
||||
|
||||
fun refreshGatewayConnection() {
|
||||
val endpoint = connectedEndpoint ?: return
|
||||
val token = prefs.loadGatewayToken()
|
||||
val password = prefs.loadGatewayPassword()
|
||||
val tls = connectionManager.resolveTlsParams(endpoint)
|
||||
operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls)
|
||||
nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls)
|
||||
val tls = resolveTlsParams(endpoint)
|
||||
operatorSession.connect(endpoint, token, password, buildOperatorConnectOptions(), tls)
|
||||
nodeSession.connect(endpoint, token, password, buildNodeConnectOptions(), tls)
|
||||
operatorSession.reconnect()
|
||||
nodeSession.reconnect()
|
||||
}
|
||||
@@ -526,9 +564,9 @@ class NodeRuntime(context: Context) {
|
||||
updateStatus()
|
||||
val token = prefs.loadGatewayToken()
|
||||
val password = prefs.loadGatewayPassword()
|
||||
val tls = connectionManager.resolveTlsParams(endpoint)
|
||||
operatorSession.connect(endpoint, token, password, connectionManager.buildOperatorConnectOptions(), tls)
|
||||
nodeSession.connect(endpoint, token, password, connectionManager.buildNodeConnectOptions(), tls)
|
||||
val tls = resolveTlsParams(endpoint)
|
||||
operatorSession.connect(endpoint, token, password, buildOperatorConnectOptions(), tls)
|
||||
nodeSession.connect(endpoint, token, password, buildNodeConnectOptions(), tls)
|
||||
}
|
||||
|
||||
private fun hasRecordAudioPermission(): Boolean {
|
||||
@@ -538,6 +576,27 @@ class NodeRuntime(context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasFineLocationPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasCoarseLocationPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasBackgroundLocationPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
fun connectManual() {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
@@ -554,6 +613,42 @@ class NodeRuntime(context: Context) {
|
||||
nodeSession.disconnect()
|
||||
}
|
||||
|
||||
private fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
|
||||
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
|
||||
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
||||
val manual = endpoint.stableId.startsWith("manual|")
|
||||
|
||||
if (manual) {
|
||||
if (!manualTls.value) return null
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
||||
allowTOFU = stored == null,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
if (hinted) {
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
||||
allowTOFU = stored == null,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
if (!stored.isNullOrBlank()) {
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = stored,
|
||||
allowTOFU = false,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
fun handleCanvasA2UIActionFromWebView(payloadJson: String) {
|
||||
scope.launch {
|
||||
val trimmed = payloadJson.trim()
|
||||
@@ -657,7 +752,15 @@ class NodeRuntime(context: Context) {
|
||||
|
||||
private fun handleGatewayEvent(event: String, payloadJson: String?) {
|
||||
if (event == "voicewake.changed") {
|
||||
gatewayEventHandler.handleVoiceWakeChangedEvent(payloadJson)
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
try {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
val array = payload["triggers"] as? JsonArray ?: return
|
||||
val triggers = array.mapNotNull { it.asStringOrNull() }
|
||||
applyWakeWordsFromGateway(triggers)
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -665,6 +768,44 @@ class NodeRuntime(context: Context) {
|
||||
chat.handleGatewayEvent(event, payloadJson)
|
||||
}
|
||||
|
||||
private fun applyWakeWordsFromGateway(words: List<String>) {
|
||||
suppressWakeWordsSync = true
|
||||
prefs.setWakeWords(words)
|
||||
suppressWakeWordsSync = false
|
||||
}
|
||||
|
||||
private fun scheduleWakeWordsSyncIfNeeded() {
|
||||
if (suppressWakeWordsSync) return
|
||||
if (!_isConnected.value) return
|
||||
|
||||
val snapshot = prefs.wakeWords.value
|
||||
wakeWordsSyncJob?.cancel()
|
||||
wakeWordsSyncJob =
|
||||
scope.launch {
|
||||
delay(650)
|
||||
val jsonList = snapshot.joinToString(separator = ",") { it.toJsonString() }
|
||||
val params = """{"triggers":[$jsonList]}"""
|
||||
try {
|
||||
operatorSession.request("voicewake.set", params)
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshWakeWordsFromGateway() {
|
||||
if (!_isConnected.value) return
|
||||
try {
|
||||
val res = operatorSession.request("voicewake.get", "{}")
|
||||
val payload = json.parseToJsonElement(res).asObjectOrNull() ?: return
|
||||
val array = payload["triggers"] as? JsonArray ?: return
|
||||
val triggers = array.mapNotNull { it.asStringOrNull() }
|
||||
applyWakeWordsFromGateway(triggers)
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshBrandingFromGateway() {
|
||||
if (!_isConnected.value) return
|
||||
try {
|
||||
@@ -684,6 +825,242 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult {
|
||||
if (
|
||||
command.startsWith(OpenClawCanvasCommand.NamespacePrefix) ||
|
||||
command.startsWith(OpenClawCanvasA2UICommand.NamespacePrefix) ||
|
||||
command.startsWith(OpenClawCameraCommand.NamespacePrefix) ||
|
||||
command.startsWith(OpenClawScreenCommand.NamespacePrefix)
|
||||
) {
|
||||
if (!isForeground.value) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
||||
)
|
||||
}
|
||||
}
|
||||
if (command.startsWith(OpenClawCameraCommand.NamespacePrefix) && !cameraEnabled.value) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CAMERA_DISABLED",
|
||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||
)
|
||||
}
|
||||
if (command.startsWith(OpenClawLocationCommand.NamespacePrefix) &&
|
||||
locationMode.value == LocationMode.Off
|
||||
) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_DISABLED",
|
||||
message = "LOCATION_DISABLED: enable Location in Settings",
|
||||
)
|
||||
}
|
||||
|
||||
return when (command) {
|
||||
OpenClawCanvasCommand.Present.rawValue -> {
|
||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||
canvas.navigate(url)
|
||||
GatewaySession.InvokeResult.ok(null)
|
||||
}
|
||||
OpenClawCanvasCommand.Hide.rawValue -> GatewaySession.InvokeResult.ok(null)
|
||||
OpenClawCanvasCommand.Navigate.rawValue -> {
|
||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||
canvas.navigate(url)
|
||||
GatewaySession.InvokeResult.ok(null)
|
||||
}
|
||||
OpenClawCanvasCommand.Eval.rawValue -> {
|
||||
val js =
|
||||
CanvasController.parseEvalJs(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: javaScript required",
|
||||
)
|
||||
val result =
|
||||
try {
|
||||
canvas.eval(js)
|
||||
} catch (err: Throwable) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||
}
|
||||
OpenClawCanvasCommand.Snapshot.rawValue -> {
|
||||
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
|
||||
val base64 =
|
||||
try {
|
||||
canvas.snapshotBase64(
|
||||
format = snapshotParams.format,
|
||||
quality = snapshotParams.quality,
|
||||
maxWidth = snapshotParams.maxWidth,
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||
}
|
||||
OpenClawCanvasA2UICommand.Reset.rawValue -> {
|
||||
val a2uiUrl = resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
val res = canvas.eval(a2uiResetJS)
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
OpenClawCanvasA2UICommand.Push.rawValue, OpenClawCanvasA2UICommand.PushJSONL.rawValue -> {
|
||||
val messages =
|
||||
try {
|
||||
decodeA2uiMessages(command, paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
return GatewaySession.InvokeResult.error(code = "INVALID_REQUEST", message = err.message ?: "invalid A2UI payload")
|
||||
}
|
||||
val a2uiUrl = resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
val js = a2uiApplyMessagesJS(messages)
|
||||
val res = canvas.eval(js)
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
OpenClawCameraCommand.Snap.rawValue -> {
|
||||
showCameraHud(message = "Taking photo…", kind = CameraHudKind.Photo)
|
||||
triggerCameraFlash()
|
||||
val res =
|
||||
try {
|
||||
camera.snap(paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2200)
|
||||
return GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
showCameraHud(message = "Photo captured", kind = CameraHudKind.Success, autoHideMs = 1600)
|
||||
GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||
}
|
||||
OpenClawCameraCommand.Clip.rawValue -> {
|
||||
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
|
||||
if (includeAudio) externalAudioCaptureActive.value = true
|
||||
try {
|
||||
showCameraHud(message = "Recording…", kind = CameraHudKind.Recording)
|
||||
val res =
|
||||
try {
|
||||
camera.clip(paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
showCameraHud(message = message, kind = CameraHudKind.Error, autoHideMs = 2400)
|
||||
return GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
showCameraHud(message = "Clip captured", kind = CameraHudKind.Success, autoHideMs = 1800)
|
||||
GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||
} finally {
|
||||
if (includeAudio) externalAudioCaptureActive.value = false
|
||||
}
|
||||
}
|
||||
OpenClawLocationCommand.Get.rawValue -> {
|
||||
val mode = locationMode.value
|
||||
if (!isForeground.value && mode != LocationMode.Always) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_BACKGROUND_UNAVAILABLE",
|
||||
message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always",
|
||||
)
|
||||
}
|
||||
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_PERMISSION_REQUIRED",
|
||||
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
|
||||
)
|
||||
}
|
||||
if (!isForeground.value && mode == LocationMode.Always && !hasBackgroundLocationPermission()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_PERMISSION_REQUIRED",
|
||||
message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings",
|
||||
)
|
||||
}
|
||||
val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson)
|
||||
val preciseEnabled = locationPreciseEnabled.value
|
||||
val accuracy =
|
||||
when (desiredAccuracy) {
|
||||
"precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
|
||||
"coarse" -> "coarse"
|
||||
else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
|
||||
}
|
||||
val providers =
|
||||
when (accuracy) {
|
||||
"precise" -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
|
||||
"coarse" -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
|
||||
else -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
|
||||
}
|
||||
try {
|
||||
val payload =
|
||||
location.getLocation(
|
||||
desiredProviders = providers,
|
||||
maxAgeMs = maxAgeMs,
|
||||
timeoutMs = timeoutMs,
|
||||
isPrecise = accuracy == "precise",
|
||||
)
|
||||
GatewaySession.InvokeResult.ok(payload.payloadJson)
|
||||
} catch (err: TimeoutCancellationException) {
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_TIMEOUT",
|
||||
message = "LOCATION_TIMEOUT: no fix in time",
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
val message = err.message ?: "LOCATION_UNAVAILABLE: no fix"
|
||||
GatewaySession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message)
|
||||
}
|
||||
}
|
||||
OpenClawScreenCommand.Record.rawValue -> {
|
||||
// Status pill mirrors screen recording state so it stays visible without overlay stacking.
|
||||
_screenRecordActive.value = true
|
||||
try {
|
||||
val res =
|
||||
try {
|
||||
screenRecorder.record(paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
return GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||
} finally {
|
||||
_screenRecordActive.value = false
|
||||
}
|
||||
}
|
||||
OpenClawSmsCommand.Send.rawValue -> {
|
||||
val res = sms.send(paramsJson)
|
||||
if (res.ok) {
|
||||
GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||
} else {
|
||||
val error = res.error ?: "SMS_SEND_FAILED"
|
||||
val idx = error.indexOf(':')
|
||||
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED"
|
||||
GatewaySession.InvokeResult.error(code = code, message = error)
|
||||
}
|
||||
}
|
||||
else ->
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: unknown command",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun triggerCameraFlash() {
|
||||
// Token is used as a pulse trigger; value doesn't matter as long as it changes.
|
||||
_cameraFlashToken.value = SystemClock.elapsedRealtimeNanos()
|
||||
@@ -701,4 +1078,194 @@ class NodeRuntime(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun invokeErrorFromThrowable(err: Throwable): Pair<String, String> {
|
||||
val raw = (err.message ?: "").trim()
|
||||
if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: camera error"
|
||||
|
||||
val idx = raw.indexOf(':')
|
||||
if (idx <= 0) return "UNAVAILABLE" to raw
|
||||
val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" }
|
||||
val message = raw.substring(idx + 1).trim().ifEmpty { raw }
|
||||
// Preserve full string for callers/logging, but keep the returned message human-friendly.
|
||||
return code to "$code: $message"
|
||||
}
|
||||
|
||||
private fun parseLocationParams(paramsJson: String?): Triple<Long?, Long, String?> {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
return Triple(null, 10_000L, null)
|
||||
}
|
||||
val root =
|
||||
try {
|
||||
json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
val maxAgeMs = (root?.get("maxAgeMs") as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
val timeoutMs =
|
||||
(root?.get("timeoutMs") as? JsonPrimitive)?.content?.toLongOrNull()?.coerceIn(1_000L, 60_000L)
|
||||
?: 10_000L
|
||||
val desiredAccuracy =
|
||||
(root?.get("desiredAccuracy") as? JsonPrimitive)?.content?.trim()?.lowercase()
|
||||
return Triple(maxAgeMs, timeoutMs, desiredAccuracy)
|
||||
}
|
||||
|
||||
private fun resolveA2uiHostUrl(): String? {
|
||||
val nodeRaw = nodeSession.currentCanvasHostUrl()?.trim().orEmpty()
|
||||
val operatorRaw = operatorSession.currentCanvasHostUrl()?.trim().orEmpty()
|
||||
val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw
|
||||
if (raw.isBlank()) return null
|
||||
val base = raw.trimEnd('/')
|
||||
return "${base}/__openclaw__/a2ui/?platform=android"
|
||||
}
|
||||
|
||||
private suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
|
||||
try {
|
||||
val already = canvas.eval(a2uiReadyCheckJS)
|
||||
if (already == "true") return true
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
canvas.navigate(a2uiUrl)
|
||||
repeat(50) {
|
||||
try {
|
||||
val ready = canvas.eval(a2uiReadyCheckJS)
|
||||
if (ready == "true") return true
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
delay(120)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun decodeA2uiMessages(command: String, paramsJson: String?): String {
|
||||
val raw = paramsJson?.trim().orEmpty()
|
||||
if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required")
|
||||
|
||||
val obj =
|
||||
json.parseToJsonElement(raw) as? JsonObject
|
||||
?: throw IllegalArgumentException("INVALID_REQUEST: expected object params")
|
||||
|
||||
val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
||||
val hasMessagesArray = obj["messages"] is JsonArray
|
||||
|
||||
if (command == OpenClawCanvasA2UICommand.PushJSONL.rawValue || (!hasMessagesArray && jsonlField.isNotBlank())) {
|
||||
val jsonl = jsonlField
|
||||
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
|
||||
val messages =
|
||||
jsonl
|
||||
.lineSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.mapIndexed { idx, line ->
|
||||
val el = json.parseToJsonElement(line)
|
||||
val msg =
|
||||
el as? JsonObject
|
||||
?: throw IllegalArgumentException("A2UI JSONL line ${idx + 1}: expected a JSON object")
|
||||
validateA2uiV0_8(msg, idx + 1)
|
||||
msg
|
||||
}
|
||||
.toList()
|
||||
return JsonArray(messages).toString()
|
||||
}
|
||||
|
||||
val arr = obj["messages"] as? JsonArray ?: throw IllegalArgumentException("INVALID_REQUEST: messages[] required")
|
||||
val out =
|
||||
arr.mapIndexed { idx, el ->
|
||||
val msg =
|
||||
el as? JsonObject
|
||||
?: throw IllegalArgumentException("A2UI messages[${idx}]: expected a JSON object")
|
||||
validateA2uiV0_8(msg, idx + 1)
|
||||
msg
|
||||
}
|
||||
return JsonArray(out).toString()
|
||||
}
|
||||
|
||||
private fun validateA2uiV0_8(msg: JsonObject, lineNumber: Int) {
|
||||
if (msg.containsKey("createSurface")) {
|
||||
throw IllegalArgumentException(
|
||||
"A2UI JSONL line $lineNumber: looks like A2UI v0.9 (`createSurface`). Canvas supports v0.8 messages only.",
|
||||
)
|
||||
}
|
||||
val allowed = setOf("beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface")
|
||||
val matched = msg.keys.filter { allowed.contains(it) }
|
||||
if (matched.size != 1) {
|
||||
val found = msg.keys.sorted().joinToString(", ")
|
||||
throw IllegalArgumentException(
|
||||
"A2UI JSONL line $lineNumber: expected exactly one of ${allowed.sorted().joinToString(", ")}; found: $found",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
|
||||
|
||||
private const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A
|
||||
|
||||
private const val a2uiReadyCheckJS: String =
|
||||
"""
|
||||
(() => {
|
||||
try {
|
||||
const host = globalThis.openclawA2UI;
|
||||
return !!host && typeof host.applyMessages === 'function';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
})()
|
||||
"""
|
||||
|
||||
private const val a2uiResetJS: String =
|
||||
"""
|
||||
(() => {
|
||||
try {
|
||||
const host = globalThis.openclawA2UI;
|
||||
if (!host) return { ok: false, error: "missing openclawA2UI" };
|
||||
return host.reset();
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e?.message ?? e) };
|
||||
}
|
||||
})()
|
||||
"""
|
||||
|
||||
private fun a2uiApplyMessagesJS(messagesJson: String): String {
|
||||
return """
|
||||
(() => {
|
||||
try {
|
||||
const host = globalThis.openclawA2UI;
|
||||
if (!host) return { ok: false, error: "missing openclawA2UI" };
|
||||
const messages = $messagesJson;
|
||||
return host.applyMessages(messages);
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e?.message ?? e) };
|
||||
}
|
||||
})()
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun String.toJsonString(): String {
|
||||
val escaped =
|
||||
this.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
return "\"$escaped\""
|
||||
}
|
||||
|
||||
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
private fun JsonElement?.asStringOrNull(): String? =
|
||||
when (this) {
|
||||
is JsonNull -> null
|
||||
is JsonPrimitive -> content
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun parseHexColorArgb(raw: String?): Long? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return null
|
||||
val hex = if (trimmed.startsWith("#")) trimmed.drop(1) else trimmed
|
||||
if (hex.length != 6) return null
|
||||
val rgb = hex.toLongOrNull(16) ?: return null
|
||||
return 0xFF000000L or rgb
|
||||
}
|
||||
|
||||
@@ -71,10 +71,6 @@ class SecurePrefs(context: Context) {
|
||||
MutableStateFlow(prefs.getBoolean("gateway.manual.tls", true))
|
||||
val manualTls: StateFlow<Boolean> = _manualTls
|
||||
|
||||
private val _gatewayToken =
|
||||
MutableStateFlow(prefs.getString("gateway.manual.token", "") ?: "")
|
||||
val gatewayToken: StateFlow<String> = _gatewayToken
|
||||
|
||||
private val _lastDiscoveredStableId =
|
||||
MutableStateFlow(
|
||||
prefs.getString("gateway.lastDiscoveredStableID", "") ?: "",
|
||||
@@ -147,19 +143,12 @@ class SecurePrefs(context: Context) {
|
||||
_manualTls.value = value
|
||||
}
|
||||
|
||||
fun setGatewayToken(value: String) {
|
||||
prefs.edit { putString("gateway.manual.token", value) }
|
||||
_gatewayToken.value = value
|
||||
}
|
||||
|
||||
fun setCanvasDebugStatusEnabled(value: Boolean) {
|
||||
prefs.edit { putBoolean("canvas.debugStatusEnabled", value) }
|
||||
_canvasDebugStatusEnabled.value = value
|
||||
}
|
||||
|
||||
fun loadGatewayToken(): String? {
|
||||
val manual = _gatewayToken.value.trim()
|
||||
if (manual.isNotEmpty()) return manual
|
||||
val key = "gateway.token.${_instanceId.value}"
|
||||
val stored = prefs.getString(key, null)?.trim()
|
||||
return stored?.takeIf { it.isNotEmpty() }
|
||||
|
||||
@@ -42,45 +42,19 @@ class DeviceIdentityStore(context: Context) {
|
||||
|
||||
fun signPayload(payload: String, identity: DeviceIdentity): String? {
|
||||
return try {
|
||||
// Use BC lightweight API directly — JCA provider registration is broken by R8
|
||||
val privateKeyBytes = Base64.decode(identity.privateKeyPkcs8Base64, Base64.DEFAULT)
|
||||
val pkInfo = org.bouncycastle.asn1.pkcs.PrivateKeyInfo.getInstance(privateKeyBytes)
|
||||
val parsed = pkInfo.parsePrivateKey()
|
||||
val rawPrivate = org.bouncycastle.asn1.DEROctetString.getInstance(parsed).octets
|
||||
val privateKey = org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters(rawPrivate, 0)
|
||||
val signer = org.bouncycastle.crypto.signers.Ed25519Signer()
|
||||
signer.init(true, privateKey)
|
||||
val payloadBytes = payload.toByteArray(Charsets.UTF_8)
|
||||
signer.update(payloadBytes, 0, payloadBytes.size)
|
||||
base64UrlEncode(signer.generateSignature())
|
||||
} catch (e: Throwable) {
|
||||
android.util.Log.e("DeviceAuth", "signPayload FAILED: ${e.javaClass.simpleName}: ${e.message}", e)
|
||||
val keySpec = PKCS8EncodedKeySpec(privateKeyBytes)
|
||||
val keyFactory = KeyFactory.getInstance("Ed25519")
|
||||
val privateKey = keyFactory.generatePrivate(keySpec)
|
||||
val signature = Signature.getInstance("Ed25519")
|
||||
signature.initSign(privateKey)
|
||||
signature.update(payload.toByteArray(Charsets.UTF_8))
|
||||
base64UrlEncode(signature.sign())
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun verifySelfSignature(payload: String, signatureBase64Url: String, identity: DeviceIdentity): Boolean {
|
||||
return try {
|
||||
val rawPublicKey = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
|
||||
val pubKey = org.bouncycastle.crypto.params.Ed25519PublicKeyParameters(rawPublicKey, 0)
|
||||
val sigBytes = base64UrlDecode(signatureBase64Url)
|
||||
val verifier = org.bouncycastle.crypto.signers.Ed25519Signer()
|
||||
verifier.init(false, pubKey)
|
||||
val payloadBytes = payload.toByteArray(Charsets.UTF_8)
|
||||
verifier.update(payloadBytes, 0, payloadBytes.size)
|
||||
verifier.verifySignature(sigBytes)
|
||||
} catch (e: Throwable) {
|
||||
android.util.Log.e("DeviceAuth", "self-verify exception: ${e.message}", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun base64UrlDecode(input: String): ByteArray {
|
||||
val normalized = input.replace('-', '+').replace('_', '/')
|
||||
val padded = normalized + "=".repeat((4 - normalized.length % 4) % 4)
|
||||
return Base64.decode(padded, Base64.DEFAULT)
|
||||
}
|
||||
|
||||
fun publicKeyBase64Url(identity: DeviceIdentity): String? {
|
||||
return try {
|
||||
val raw = Base64.decode(identity.publicKeyRawBase64, Base64.DEFAULT)
|
||||
@@ -123,21 +97,15 @@ class DeviceIdentityStore(context: Context) {
|
||||
}
|
||||
|
||||
private fun generate(): DeviceIdentity {
|
||||
// Use BC lightweight API directly to avoid JCA provider issues with R8
|
||||
val kpGen = org.bouncycastle.crypto.generators.Ed25519KeyPairGenerator()
|
||||
kpGen.init(org.bouncycastle.crypto.params.Ed25519KeyGenerationParameters(java.security.SecureRandom()))
|
||||
val kp = kpGen.generateKeyPair()
|
||||
val pubKey = kp.public as org.bouncycastle.crypto.params.Ed25519PublicKeyParameters
|
||||
val privKey = kp.private as org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters
|
||||
val rawPublic = pubKey.encoded // 32 bytes
|
||||
val keyPair = KeyPairGenerator.getInstance("Ed25519").generateKeyPair()
|
||||
val spki = keyPair.public.encoded
|
||||
val rawPublic = stripSpkiPrefix(spki)
|
||||
val deviceId = sha256Hex(rawPublic)
|
||||
// Encode private key as PKCS8 for storage
|
||||
val privKeyInfo = org.bouncycastle.crypto.util.PrivateKeyInfoFactory.createPrivateKeyInfo(privKey)
|
||||
val pkcs8Bytes = privKeyInfo.encoded
|
||||
val privateKey = keyPair.private.encoded
|
||||
return DeviceIdentity(
|
||||
deviceId = deviceId,
|
||||
publicKeyRawBase64 = Base64.encodeToString(rawPublic, Base64.NO_WRAP),
|
||||
privateKeyPkcs8Base64 = Base64.encodeToString(pkcs8Bytes, Base64.NO_WRAP),
|
||||
privateKeyPkcs8Base64 = Base64.encodeToString(privateKey, Base64.NO_WRAP),
|
||||
createdAtMs = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -193,9 +193,7 @@ class GatewaySession(
|
||||
suspend fun connect() {
|
||||
val scheme = if (tls != null) "wss" else "ws"
|
||||
val url = "$scheme://${endpoint.host}:${endpoint.port}"
|
||||
val httpScheme = if (tls != null) "https" else "http"
|
||||
val origin = "$httpScheme://${endpoint.host}:${endpoint.port}"
|
||||
val request = Request.Builder().url(url).header("Origin", origin).build()
|
||||
val request = Request.Builder().url(url).build()
|
||||
socket = client.newWebSocket(request, Listener())
|
||||
try {
|
||||
connectDeferred.await()
|
||||
@@ -243,9 +241,6 @@ class GatewaySession(
|
||||
|
||||
private fun buildClient(): OkHttpClient {
|
||||
val builder = OkHttpClient.Builder()
|
||||
.writeTimeout(60, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(0, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.pingInterval(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
val tlsConfig = buildGatewayTlsConfig(tls) { fingerprint ->
|
||||
onTlsFingerprint?.invoke(tls?.stableId ?: endpoint.stableId, fingerprint)
|
||||
}
|
||||
@@ -624,18 +619,7 @@ class GatewaySession(
|
||||
val port = parsed?.port ?: -1
|
||||
val scheme = parsed?.scheme?.trim().orEmpty().ifBlank { "http" }
|
||||
|
||||
// Detect TLS reverse proxy: endpoint on port 443, or domain-based host
|
||||
val tls = endpoint.port == 443 || endpoint.host.contains(".")
|
||||
|
||||
// If raw URL is a non-loopback address AND we're behind TLS reverse proxy,
|
||||
// fix the port (gateway sends its internal port like 18789, but we need 443 via Caddy)
|
||||
if (trimmed.isNotBlank() && !isLoopbackHost(host)) {
|
||||
if (tls && port > 0 && port != 443) {
|
||||
// Rewrite the URL to use the reverse proxy port instead of the raw gateway port
|
||||
val fixedScheme = "https"
|
||||
val formattedHost = if (host.contains(":")) "[${host}]" else host
|
||||
return "$fixedScheme://$formattedHost"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
@@ -645,14 +629,9 @@ class GatewaySession(
|
||||
?: endpoint.host.trim()
|
||||
if (fallbackHost.isEmpty()) return trimmed.ifBlank { null }
|
||||
|
||||
// When connecting through a reverse proxy (TLS on standard port), use the
|
||||
// connection endpoint's scheme and port instead of the raw canvas port.
|
||||
val fallbackScheme = if (tls) "https" else scheme
|
||||
// Behind reverse proxy, always use the proxy port (443), not the raw canvas port
|
||||
val fallbackPort = if (tls) endpoint.port else (endpoint.canvasPort ?: endpoint.port)
|
||||
val fallbackPort = endpoint.canvasPort ?: if (port > 0) port else 18793
|
||||
val formattedHost = if (fallbackHost.contains(":")) "[${fallbackHost}]" else fallbackHost
|
||||
val portSuffix = if ((fallbackScheme == "https" && fallbackPort == 443) || (fallbackScheme == "http" && fallbackPort == 80)) "" else ":$fallbackPort"
|
||||
return "$fallbackScheme://$formattedHost$portSuffix"
|
||||
return "$scheme://$formattedHost:$fallbackPort"
|
||||
}
|
||||
|
||||
private fun isLoopbackHost(raw: String?): Boolean {
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
class A2UIHandler(
|
||||
private val canvas: CanvasController,
|
||||
private val json: Json,
|
||||
private val getNodeCanvasHostUrl: () -> String?,
|
||||
private val getOperatorCanvasHostUrl: () -> String?,
|
||||
) {
|
||||
fun resolveA2uiHostUrl(): String? {
|
||||
val nodeRaw = getNodeCanvasHostUrl()?.trim().orEmpty()
|
||||
val operatorRaw = getOperatorCanvasHostUrl()?.trim().orEmpty()
|
||||
val raw = if (nodeRaw.isNotBlank()) nodeRaw else operatorRaw
|
||||
if (raw.isBlank()) return null
|
||||
val base = raw.trimEnd('/')
|
||||
return "${base}/__openclaw__/a2ui/?platform=android"
|
||||
}
|
||||
|
||||
suspend fun ensureA2uiReady(a2uiUrl: String): Boolean {
|
||||
try {
|
||||
val already = canvas.eval(a2uiReadyCheckJS)
|
||||
if (already == "true") return true
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
canvas.navigate(a2uiUrl)
|
||||
repeat(50) {
|
||||
try {
|
||||
val ready = canvas.eval(a2uiReadyCheckJS)
|
||||
if (ready == "true") return true
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
delay(120)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun decodeA2uiMessages(command: String, paramsJson: String?): String {
|
||||
val raw = paramsJson?.trim().orEmpty()
|
||||
if (raw.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: paramsJSON required")
|
||||
|
||||
val obj =
|
||||
json.parseToJsonElement(raw) as? JsonObject
|
||||
?: throw IllegalArgumentException("INVALID_REQUEST: expected object params")
|
||||
|
||||
val jsonlField = (obj["jsonl"] as? JsonPrimitive)?.content?.trim().orEmpty()
|
||||
val hasMessagesArray = obj["messages"] is JsonArray
|
||||
|
||||
if (command == "canvas.a2ui.pushJSONL" || (!hasMessagesArray && jsonlField.isNotBlank())) {
|
||||
val jsonl = jsonlField
|
||||
if (jsonl.isBlank()) throw IllegalArgumentException("INVALID_REQUEST: jsonl required")
|
||||
val messages =
|
||||
jsonl
|
||||
.lineSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.mapIndexed { idx, line ->
|
||||
val el = json.parseToJsonElement(line)
|
||||
val msg =
|
||||
el as? JsonObject
|
||||
?: throw IllegalArgumentException("A2UI JSONL line ${idx + 1}: expected a JSON object")
|
||||
validateA2uiV0_8(msg, idx + 1)
|
||||
msg
|
||||
}
|
||||
.toList()
|
||||
return JsonArray(messages).toString()
|
||||
}
|
||||
|
||||
val arr = obj["messages"] as? JsonArray ?: throw IllegalArgumentException("INVALID_REQUEST: messages[] required")
|
||||
val out =
|
||||
arr.mapIndexed { idx, el ->
|
||||
val msg =
|
||||
el as? JsonObject
|
||||
?: throw IllegalArgumentException("A2UI messages[${idx}]: expected a JSON object")
|
||||
validateA2uiV0_8(msg, idx + 1)
|
||||
msg
|
||||
}
|
||||
return JsonArray(out).toString()
|
||||
}
|
||||
|
||||
private fun validateA2uiV0_8(msg: JsonObject, lineNumber: Int) {
|
||||
if (msg.containsKey("createSurface")) {
|
||||
throw IllegalArgumentException(
|
||||
"A2UI JSONL line $lineNumber: looks like A2UI v0.9 (`createSurface`). Canvas supports v0.8 messages only.",
|
||||
)
|
||||
}
|
||||
val allowed = setOf("beginRendering", "surfaceUpdate", "dataModelUpdate", "deleteSurface")
|
||||
val matched = msg.keys.filter { allowed.contains(it) }
|
||||
if (matched.size != 1) {
|
||||
val found = msg.keys.sorted().joinToString(", ")
|
||||
throw IllegalArgumentException(
|
||||
"A2UI JSONL line $lineNumber: expected exactly one of ${allowed.sorted().joinToString(", ")}; found: $found",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val a2uiReadyCheckJS: String =
|
||||
"""
|
||||
(() => {
|
||||
try {
|
||||
const host = globalThis.openclawA2UI;
|
||||
return !!host && typeof host.applyMessages === 'function';
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
})()
|
||||
"""
|
||||
|
||||
const val a2uiResetJS: String =
|
||||
"""
|
||||
(() => {
|
||||
try {
|
||||
const host = globalThis.openclawA2UI;
|
||||
if (!host) return { ok: false, error: "missing openclawA2UI" };
|
||||
return host.reset();
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e?.message ?? e) };
|
||||
}
|
||||
})()
|
||||
"""
|
||||
|
||||
fun a2uiApplyMessagesJS(messagesJson: String): String {
|
||||
return """
|
||||
(() => {
|
||||
try {
|
||||
const host = globalThis.openclawA2UI;
|
||||
if (!host) return { ok: false, error: "missing openclawA2UI" };
|
||||
const messages = $messagesJson;
|
||||
return host.applyMessages(messages);
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e?.message ?? e) };
|
||||
}
|
||||
})()
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import ai.openclaw.android.InstallResultReceiver
|
||||
import ai.openclaw.android.MainActivity
|
||||
import ai.openclaw.android.gateway.GatewayEndpoint
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.security.MessageDigest
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
private val SHA256_HEX = Regex("^[a-fA-F0-9]{64}$")
|
||||
|
||||
internal data class AppUpdateRequest(
|
||||
val url: String,
|
||||
val expectedSha256: String,
|
||||
)
|
||||
|
||||
internal fun parseAppUpdateRequest(paramsJson: String?, connectedHost: String?): AppUpdateRequest {
|
||||
val params =
|
||||
try {
|
||||
paramsJson?.let { Json.parseToJsonElement(it).jsonObject }
|
||||
} catch (_: Throwable) {
|
||||
throw IllegalArgumentException("params must be valid JSON")
|
||||
} ?: throw IllegalArgumentException("missing 'url' parameter")
|
||||
|
||||
val urlRaw =
|
||||
params["url"]?.jsonPrimitive?.content?.trim().orEmpty()
|
||||
.ifEmpty { throw IllegalArgumentException("missing 'url' parameter") }
|
||||
val sha256Raw =
|
||||
params["sha256"]?.jsonPrimitive?.content?.trim().orEmpty()
|
||||
.ifEmpty { throw IllegalArgumentException("missing 'sha256' parameter") }
|
||||
if (!SHA256_HEX.matches(sha256Raw)) {
|
||||
throw IllegalArgumentException("invalid 'sha256' parameter (expected 64 hex chars)")
|
||||
}
|
||||
|
||||
val uri =
|
||||
try {
|
||||
URI(urlRaw)
|
||||
} catch (_: Throwable) {
|
||||
throw IllegalArgumentException("invalid 'url' parameter")
|
||||
}
|
||||
val scheme = uri.scheme?.lowercase(Locale.US).orEmpty()
|
||||
if (scheme != "https") {
|
||||
throw IllegalArgumentException("url must use https")
|
||||
}
|
||||
if (!uri.userInfo.isNullOrBlank()) {
|
||||
throw IllegalArgumentException("url must not include credentials")
|
||||
}
|
||||
val host = uri.host?.lowercase(Locale.US) ?: throw IllegalArgumentException("url host required")
|
||||
val connectedHostNormalized = connectedHost?.trim()?.lowercase(Locale.US).orEmpty()
|
||||
if (connectedHostNormalized.isNotEmpty() && host != connectedHostNormalized) {
|
||||
throw IllegalArgumentException("url host must match connected gateway host")
|
||||
}
|
||||
|
||||
return AppUpdateRequest(
|
||||
url = uri.toASCIIString(),
|
||||
expectedSha256 = sha256Raw.lowercase(Locale.US),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun sha256Hex(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
if (read == 0) continue
|
||||
digest.update(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
val out = StringBuilder(64)
|
||||
for (byte in digest.digest()) {
|
||||
out.append(String.format(Locale.US, "%02x", byte))
|
||||
}
|
||||
return out.toString()
|
||||
}
|
||||
|
||||
class AppUpdateHandler(
|
||||
private val appContext: Context,
|
||||
private val connectedEndpoint: () -> GatewayEndpoint?,
|
||||
) {
|
||||
|
||||
fun handleUpdate(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
try {
|
||||
val updateRequest =
|
||||
try {
|
||||
parseAppUpdateRequest(paramsJson, connectedEndpoint()?.host)
|
||||
} catch (err: IllegalArgumentException) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: ${err.message ?: "invalid app.update params"}",
|
||||
)
|
||||
}
|
||||
val url = updateRequest.url
|
||||
val expectedSha256 = updateRequest.expectedSha256
|
||||
|
||||
android.util.Log.w("openclaw", "app.update: downloading from $url")
|
||||
|
||||
val notifId = 9001
|
||||
val channelId = "app_update"
|
||||
val notifManager = appContext.getSystemService(android.content.Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
|
||||
|
||||
// Create notification channel (required for Android 8+)
|
||||
val channel = android.app.NotificationChannel(channelId, "App Updates", android.app.NotificationManager.IMPORTANCE_LOW)
|
||||
notifManager.createNotificationChannel(channel)
|
||||
|
||||
// PendingIntent to open the app when notification is tapped
|
||||
val launchIntent = Intent(appContext, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
}
|
||||
val launchPi = PendingIntent.getActivity(appContext, 0, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||
|
||||
// Launch download async so the invoke returns immediately
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val cacheDir = java.io.File(appContext.cacheDir, "updates")
|
||||
cacheDir.mkdirs()
|
||||
val file = java.io.File(cacheDir, "update.apk")
|
||||
if (file.exists()) file.delete()
|
||||
|
||||
// Show initial progress notification
|
||||
fun buildProgressNotif(progress: Int, max: Int, text: String): android.app.Notification {
|
||||
return android.app.Notification.Builder(appContext, channelId)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download)
|
||||
.setContentTitle("OpenClaw Update")
|
||||
.setContentText(text)
|
||||
.setProgress(max, progress, max == 0)
|
||||
|
||||
.setContentIntent(launchPi)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
notifManager.notify(notifId, buildProgressNotif(0, 0, "Connecting..."))
|
||||
|
||||
val client = okhttp3.OkHttpClient.Builder()
|
||||
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(300, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
val request = okhttp3.Request.Builder().url(url).build()
|
||||
val response = client.newCall(request).execute()
|
||||
if (!response.isSuccessful) {
|
||||
notifManager.cancel(notifId)
|
||||
notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setContentTitle("Update Failed")
|
||||
|
||||
.setContentIntent(launchPi)
|
||||
.setContentText("HTTP ${response.code}")
|
||||
.build())
|
||||
return@launch
|
||||
}
|
||||
|
||||
val contentLength = response.body?.contentLength() ?: -1L
|
||||
val body = response.body ?: run {
|
||||
notifManager.cancel(notifId)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Download with progress tracking
|
||||
var totalBytes = 0L
|
||||
var lastNotifUpdate = 0L
|
||||
body.byteStream().use { input ->
|
||||
file.outputStream().use { output ->
|
||||
val buffer = ByteArray(8192)
|
||||
while (true) {
|
||||
val bytesRead = input.read(buffer)
|
||||
if (bytesRead == -1) break
|
||||
output.write(buffer, 0, bytesRead)
|
||||
totalBytes += bytesRead
|
||||
|
||||
// Update notification at most every 500ms
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastNotifUpdate > 500) {
|
||||
lastNotifUpdate = now
|
||||
if (contentLength > 0) {
|
||||
val pct = ((totalBytes * 100) / contentLength).toInt()
|
||||
val mb = String.format("%.1f", totalBytes / 1048576.0)
|
||||
val totalMb = String.format("%.1f", contentLength / 1048576.0)
|
||||
notifManager.notify(notifId, buildProgressNotif(pct, 100, "$mb / $totalMb MB ($pct%)"))
|
||||
} else {
|
||||
val mb = String.format("%.1f", totalBytes / 1048576.0)
|
||||
notifManager.notify(notifId, buildProgressNotif(0, 0, "${mb} MB downloaded"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android.util.Log.w("openclaw", "app.update: downloaded ${file.length()} bytes")
|
||||
val actualSha256 = sha256Hex(file)
|
||||
if (actualSha256 != expectedSha256) {
|
||||
android.util.Log.e(
|
||||
"openclaw",
|
||||
"app.update: sha256 mismatch expected=$expectedSha256 actual=$actualSha256",
|
||||
)
|
||||
file.delete()
|
||||
notifManager.cancel(notifId)
|
||||
notifManager.notify(
|
||||
notifId,
|
||||
android.app.Notification.Builder(appContext, channelId)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setContentTitle("Update Failed")
|
||||
.setContentIntent(launchPi)
|
||||
.setContentText("SHA-256 mismatch")
|
||||
.build(),
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Verify file is a valid APK (basic check: ZIP magic bytes)
|
||||
val magic = file.inputStream().use { it.read().toByte() to it.read().toByte() }
|
||||
if (magic.first != 0x50.toByte() || magic.second != 0x4B.toByte()) {
|
||||
android.util.Log.e("openclaw", "app.update: invalid APK (bad magic: ${magic.first}, ${magic.second})")
|
||||
file.delete()
|
||||
notifManager.cancel(notifId)
|
||||
notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setContentTitle("Update Failed")
|
||||
|
||||
.setContentIntent(launchPi)
|
||||
.setContentText("Downloaded file is not a valid APK")
|
||||
.build())
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Use PackageInstaller session API — works from background on API 34+
|
||||
// The system handles showing the install confirmation dialog
|
||||
notifManager.cancel(notifId)
|
||||
notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setContentTitle("Installing Update...")
|
||||
|
||||
.setContentIntent(launchPi)
|
||||
.setContentText("${String.format("%.1f", totalBytes / 1048576.0)} MB downloaded")
|
||||
.build())
|
||||
|
||||
val installer = appContext.packageManager.packageInstaller
|
||||
val params = android.content.pm.PackageInstaller.SessionParams(
|
||||
android.content.pm.PackageInstaller.SessionParams.MODE_FULL_INSTALL
|
||||
)
|
||||
params.setSize(file.length())
|
||||
val sessionId = installer.createSession(params)
|
||||
val session = installer.openSession(sessionId)
|
||||
session.openWrite("openclaw-update.apk", 0, file.length()).use { out ->
|
||||
file.inputStream().use { inp -> inp.copyTo(out) }
|
||||
session.fsync(out)
|
||||
}
|
||||
// Commit with FLAG_MUTABLE PendingIntent — system requires mutable for PackageInstaller status
|
||||
val callbackIntent = android.content.Intent(appContext, InstallResultReceiver::class.java)
|
||||
val pi = android.app.PendingIntent.getBroadcast(
|
||||
appContext, sessionId, callbackIntent,
|
||||
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE
|
||||
)
|
||||
session.commit(pi.intentSender)
|
||||
android.util.Log.w("openclaw", "app.update: PackageInstaller session committed, waiting for user confirmation")
|
||||
} catch (err: Throwable) {
|
||||
android.util.Log.e("openclaw", "app.update: async error", err)
|
||||
notifManager.cancel(notifId)
|
||||
notifManager.notify(notifId, android.app.Notification.Builder(appContext, channelId)
|
||||
.setSmallIcon(android.R.drawable.stat_notify_error)
|
||||
.setContentTitle("Update Failed")
|
||||
|
||||
.setContentIntent(launchPi)
|
||||
.setContentText(err.message ?: "Unknown error")
|
||||
.build())
|
||||
}
|
||||
}
|
||||
|
||||
// Return immediately — download happens in background
|
||||
return GatewaySession.InvokeResult.ok(buildJsonObject {
|
||||
put("status", "downloading")
|
||||
put("url", url)
|
||||
put("sha256", expectedSha256)
|
||||
}.toString())
|
||||
} catch (err: Throwable) {
|
||||
android.util.Log.e("openclaw", "app.update: error", err)
|
||||
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "update failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,6 @@ import androidx.camera.core.ImageCapture
|
||||
import androidx.camera.core.ImageCaptureException
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
import androidx.camera.video.FileOutputOptions
|
||||
import androidx.camera.video.FallbackStrategy
|
||||
import androidx.camera.video.Quality
|
||||
import androidx.camera.video.QualitySelector
|
||||
import androidx.camera.video.Recorder
|
||||
import androidx.camera.video.Recording
|
||||
import androidx.camera.video.VideoCapture
|
||||
@@ -39,7 +36,6 @@ import kotlin.coroutines.resumeWithException
|
||||
|
||||
class CameraCaptureManager(private val context: Context) {
|
||||
data class Payload(val payloadJson: String)
|
||||
data class FilePayload(val file: File, val durationMs: Long, val hasAudio: Boolean)
|
||||
|
||||
@Volatile private var lifecycleOwner: LifecycleOwner? = null
|
||||
@Volatile private var permissionRequester: PermissionRequester? = null
|
||||
@@ -81,8 +77,8 @@ class CameraCaptureManager(private val context: Context) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
val facing = parseFacing(paramsJson) ?: "front"
|
||||
val quality = (parseQuality(paramsJson) ?: 0.5).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(paramsJson) ?: 800
|
||||
val quality = (parseQuality(paramsJson) ?: 0.9).coerceIn(0.1, 1.0)
|
||||
val maxWidth = parseMaxWidth(paramsJson)
|
||||
|
||||
val provider = context.cameraProvider()
|
||||
val capture = ImageCapture.Builder().build()
|
||||
@@ -97,7 +93,7 @@ class CameraCaptureManager(private val context: Context) {
|
||||
?: throw IllegalStateException("UNAVAILABLE: failed to decode captured image")
|
||||
val rotated = rotateBitmapByExif(decoded, orientation)
|
||||
val scaled =
|
||||
if (maxWidth > 0 && rotated.width > maxWidth) {
|
||||
if (maxWidth != null && maxWidth > 0 && rotated.width > maxWidth) {
|
||||
val h =
|
||||
(rotated.height.toDouble() * (maxWidth.toDouble() / rotated.width.toDouble()))
|
||||
.toInt()
|
||||
@@ -141,7 +137,7 @@ class CameraCaptureManager(private val context: Context) {
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun clip(paramsJson: String?): FilePayload =
|
||||
suspend fun clip(paramsJson: String?): Payload =
|
||||
withContext(Dispatchers.Main) {
|
||||
ensureCameraPermission()
|
||||
val owner = lifecycleOwner ?: throw IllegalStateException("UNAVAILABLE: camera not ready")
|
||||
@@ -150,49 +146,19 @@ class CameraCaptureManager(private val context: Context) {
|
||||
val includeAudio = parseIncludeAudio(paramsJson) ?: true
|
||||
if (includeAudio) ensureMicPermission()
|
||||
|
||||
android.util.Log.w("CameraCaptureManager", "clip: start facing=$facing duration=$durationMs audio=$includeAudio")
|
||||
|
||||
val provider = context.cameraProvider()
|
||||
android.util.Log.w("CameraCaptureManager", "clip: got camera provider")
|
||||
|
||||
// Use LOWEST quality for smallest files over WebSocket
|
||||
val recorder = Recorder.Builder()
|
||||
.setQualitySelector(
|
||||
QualitySelector.from(Quality.LOWEST, FallbackStrategy.lowerQualityOrHigherThan(Quality.LOWEST))
|
||||
)
|
||||
.build()
|
||||
val recorder = Recorder.Builder().build()
|
||||
val videoCapture = VideoCapture.withOutput(recorder)
|
||||
val selector =
|
||||
if (facing == "front") CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA
|
||||
|
||||
// CameraX requires a Preview use case for the camera to start producing frames;
|
||||
// without it, the encoder may get no data (ERROR_NO_VALID_DATA).
|
||||
val preview = androidx.camera.core.Preview.Builder().build()
|
||||
// Provide a dummy SurfaceTexture so the preview pipeline activates
|
||||
val surfaceTexture = android.graphics.SurfaceTexture(0)
|
||||
surfaceTexture.setDefaultBufferSize(640, 480)
|
||||
preview.setSurfaceProvider { request ->
|
||||
val surface = android.view.Surface(surfaceTexture)
|
||||
request.provideSurface(surface, context.mainExecutor()) { result ->
|
||||
surface.release()
|
||||
surfaceTexture.release()
|
||||
}
|
||||
}
|
||||
|
||||
provider.unbindAll()
|
||||
android.util.Log.w("CameraCaptureManager", "clip: binding preview + videoCapture to lifecycle")
|
||||
val camera = provider.bindToLifecycle(owner, selector, preview, videoCapture)
|
||||
android.util.Log.w("CameraCaptureManager", "clip: bound, cameraInfo=${camera.cameraInfo}")
|
||||
|
||||
// Give camera pipeline time to initialize before recording
|
||||
android.util.Log.w("CameraCaptureManager", "clip: warming up camera 1.5s...")
|
||||
kotlinx.coroutines.delay(1_500)
|
||||
provider.bindToLifecycle(owner, selector, videoCapture)
|
||||
|
||||
val file = File.createTempFile("openclaw-clip-", ".mp4")
|
||||
val outputOptions = FileOutputOptions.Builder(file).build()
|
||||
|
||||
val finalized = kotlinx.coroutines.CompletableDeferred<VideoRecordEvent.Finalize>()
|
||||
android.util.Log.w("CameraCaptureManager", "clip: starting recording to ${file.absolutePath}")
|
||||
val recording: Recording =
|
||||
videoCapture.output
|
||||
.prepareRecording(context, outputOptions)
|
||||
@@ -200,49 +166,35 @@ class CameraCaptureManager(private val context: Context) {
|
||||
if (includeAudio) withAudioEnabled()
|
||||
}
|
||||
.start(context.mainExecutor()) { event ->
|
||||
android.util.Log.w("CameraCaptureManager", "clip: event ${event.javaClass.simpleName}")
|
||||
if (event is VideoRecordEvent.Status) {
|
||||
android.util.Log.w("CameraCaptureManager", "clip: recording status update")
|
||||
}
|
||||
if (event is VideoRecordEvent.Finalize) {
|
||||
android.util.Log.w("CameraCaptureManager", "clip: finalize hasError=${event.hasError()} error=${event.error} cause=${event.cause}")
|
||||
finalized.complete(event)
|
||||
}
|
||||
}
|
||||
|
||||
android.util.Log.w("CameraCaptureManager", "clip: recording started, delaying ${durationMs}ms")
|
||||
try {
|
||||
kotlinx.coroutines.delay(durationMs.toLong())
|
||||
} finally {
|
||||
android.util.Log.w("CameraCaptureManager", "clip: stopping recording")
|
||||
recording.stop()
|
||||
}
|
||||
|
||||
val finalizeEvent =
|
||||
try {
|
||||
withTimeout(15_000) { finalized.await() }
|
||||
withTimeout(10_000) { finalized.await() }
|
||||
} catch (err: Throwable) {
|
||||
android.util.Log.e("CameraCaptureManager", "clip: finalize timed out", err)
|
||||
withContext(Dispatchers.IO) { file.delete() }
|
||||
provider.unbindAll()
|
||||
file.delete()
|
||||
throw IllegalStateException("UNAVAILABLE: camera clip finalize timed out")
|
||||
}
|
||||
if (finalizeEvent.hasError()) {
|
||||
android.util.Log.e("CameraCaptureManager", "clip: FAILED error=${finalizeEvent.error}, cause=${finalizeEvent.cause}", finalizeEvent.cause)
|
||||
// Check file size for debugging
|
||||
val fileSize = withContext(Dispatchers.IO) { if (file.exists()) file.length() else -1 }
|
||||
android.util.Log.e("CameraCaptureManager", "clip: file exists=${file.exists()} size=$fileSize")
|
||||
withContext(Dispatchers.IO) { file.delete() }
|
||||
provider.unbindAll()
|
||||
throw IllegalStateException("UNAVAILABLE: camera clip failed (error=${finalizeEvent.error})")
|
||||
file.delete()
|
||||
throw IllegalStateException("UNAVAILABLE: camera clip failed")
|
||||
}
|
||||
|
||||
val fileSize = withContext(Dispatchers.IO) { file.length() }
|
||||
android.util.Log.w("CameraCaptureManager", "clip: SUCCESS file size=$fileSize")
|
||||
|
||||
provider.unbindAll()
|
||||
|
||||
FilePayload(file = file, durationMs = durationMs.toLong(), hasAudio = includeAudio)
|
||||
val bytes = file.readBytes()
|
||||
file.delete()
|
||||
val base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
Payload(
|
||||
"""{"format":"mp4","base64":"$base64","durationMs":$durationMs,"hasAudio":${includeAudio}}""",
|
||||
)
|
||||
}
|
||||
|
||||
private fun rotateBitmapByExif(bitmap: Bitmap, orientation: Int): Bitmap {
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import ai.openclaw.android.CameraHudKind
|
||||
import ai.openclaw.android.BuildConfig
|
||||
import ai.openclaw.android.SecurePrefs
|
||||
import ai.openclaw.android.gateway.GatewayEndpoint
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
|
||||
class CameraHandler(
|
||||
private val appContext: Context,
|
||||
private val camera: CameraCaptureManager,
|
||||
private val prefs: SecurePrefs,
|
||||
private val connectedEndpoint: () -> GatewayEndpoint?,
|
||||
private val externalAudioCaptureActive: MutableStateFlow<Boolean>,
|
||||
private val showCameraHud: (message: String, kind: CameraHudKind, autoHideMs: Long?) -> Unit,
|
||||
private val triggerCameraFlash: () -> Unit,
|
||||
private val invokeErrorFromThrowable: (err: Throwable) -> Pair<String, String>,
|
||||
) {
|
||||
|
||||
suspend fun handleSnap(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val logFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
|
||||
fun camLog(msg: String) {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date())
|
||||
logFile?.appendText("[$ts] $msg\n")
|
||||
android.util.Log.w("openclaw", "camera.snap: $msg")
|
||||
}
|
||||
try {
|
||||
logFile?.writeText("") // clear
|
||||
camLog("starting, params=$paramsJson")
|
||||
camLog("calling showCameraHud")
|
||||
showCameraHud("Taking photo…", CameraHudKind.Photo, null)
|
||||
camLog("calling triggerCameraFlash")
|
||||
triggerCameraFlash()
|
||||
val res =
|
||||
try {
|
||||
camLog("calling camera.snap()")
|
||||
val r = camera.snap(paramsJson)
|
||||
camLog("success, payload size=${r.payloadJson.length}")
|
||||
r
|
||||
} catch (err: Throwable) {
|
||||
camLog("inner error: ${err::class.java.simpleName}: ${err.message}")
|
||||
camLog("stack: ${err.stackTraceToString().take(2000)}")
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
showCameraHud(message, CameraHudKind.Error, 2200)
|
||||
return GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
camLog("returning result")
|
||||
showCameraHud("Photo captured", CameraHudKind.Success, 1600)
|
||||
return GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||
} catch (err: Throwable) {
|
||||
camLog("outer error: ${err::class.java.simpleName}: ${err.message}")
|
||||
camLog("stack: ${err.stackTraceToString().take(2000)}")
|
||||
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "camera snap failed")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun handleClip(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val clipLogFile = if (BuildConfig.DEBUG) java.io.File(appContext.cacheDir, "camera_debug.log") else null
|
||||
fun clipLog(msg: String) {
|
||||
if (!BuildConfig.DEBUG) return
|
||||
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.US).format(java.util.Date())
|
||||
clipLogFile?.appendText("[CLIP $ts] $msg\n")
|
||||
android.util.Log.w("openclaw", "camera.clip: $msg")
|
||||
}
|
||||
val includeAudio = paramsJson?.contains("\"includeAudio\":true") != false
|
||||
if (includeAudio) externalAudioCaptureActive.value = true
|
||||
try {
|
||||
clipLogFile?.writeText("") // clear
|
||||
clipLog("starting, params=$paramsJson includeAudio=$includeAudio")
|
||||
clipLog("calling showCameraHud")
|
||||
showCameraHud("Recording…", CameraHudKind.Recording, null)
|
||||
val filePayload =
|
||||
try {
|
||||
clipLog("calling camera.clip()")
|
||||
val r = camera.clip(paramsJson)
|
||||
clipLog("success, file size=${r.file.length()}")
|
||||
r
|
||||
} catch (err: Throwable) {
|
||||
clipLog("inner error: ${err::class.java.simpleName}: ${err.message}")
|
||||
clipLog("stack: ${err.stackTraceToString().take(2000)}")
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
showCameraHud(message, CameraHudKind.Error, 2400)
|
||||
return GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
// Upload file via HTTP instead of base64 through WebSocket
|
||||
clipLog("uploading via HTTP...")
|
||||
val uploadUrl = try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val ep = connectedEndpoint()
|
||||
val gatewayHost = if (ep != null) {
|
||||
val isHttps = ep.tlsEnabled || ep.port == 443
|
||||
if (!isHttps) {
|
||||
clipLog("refusing to upload over plain HTTP — bearer token would be exposed; falling back to base64")
|
||||
throw Exception("HTTPS required for upload (bearer token protection)")
|
||||
}
|
||||
if (ep.port == 443) "https://${ep.host}" else "https://${ep.host}:${ep.port}"
|
||||
} else {
|
||||
clipLog("error: no gateway endpoint connected, cannot upload")
|
||||
throw Exception("no gateway endpoint connected")
|
||||
}
|
||||
val token = prefs.loadGatewayToken() ?: ""
|
||||
val client = okhttp3.OkHttpClient.Builder()
|
||||
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.writeTimeout(120, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
val body = filePayload.file.asRequestBody("video/mp4".toMediaType())
|
||||
val req = okhttp3.Request.Builder()
|
||||
.url("$gatewayHost/upload/clip.mp4")
|
||||
.put(body)
|
||||
.header("Authorization", "Bearer $token")
|
||||
.build()
|
||||
clipLog("uploading ${filePayload.file.length()} bytes to $gatewayHost/upload/clip.mp4")
|
||||
val resp = client.newCall(req).execute()
|
||||
val respBody = resp.body?.string() ?: ""
|
||||
clipLog("upload response: ${resp.code} $respBody")
|
||||
filePayload.file.delete()
|
||||
if (!resp.isSuccessful) throw Exception("upload failed: HTTP ${resp.code}")
|
||||
// Parse URL from response
|
||||
val urlMatch = Regex("\"url\":\"([^\"]+)\"").find(respBody)
|
||||
urlMatch?.groupValues?.get(1) ?: throw Exception("no url in response: $respBody")
|
||||
}
|
||||
} catch (err: Throwable) {
|
||||
clipLog("upload failed: ${err.message}, falling back to base64")
|
||||
// Fallback to base64 if upload fails
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
val b = filePayload.file.readBytes()
|
||||
filePayload.file.delete()
|
||||
b
|
||||
}
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
showCameraHud("Clip captured", CameraHudKind.Success, 1800)
|
||||
return GatewaySession.InvokeResult.ok(
|
||||
"""{"format":"mp4","base64":"$base64","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}"""
|
||||
)
|
||||
}
|
||||
clipLog("returning URL result: $uploadUrl")
|
||||
showCameraHud("Clip captured", CameraHudKind.Success, 1800)
|
||||
return GatewaySession.InvokeResult.ok(
|
||||
"""{"format":"mp4","url":"$uploadUrl","durationMs":${filePayload.durationMs},"hasAudio":${filePayload.hasAudio}}"""
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
clipLog("outer error: ${err::class.java.simpleName}: ${err.message}")
|
||||
clipLog("stack: ${err.stackTraceToString().take(2000)}")
|
||||
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = err.message ?: "camera clip failed")
|
||||
} finally {
|
||||
if (includeAudio) externalAudioCaptureActive.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.os.Build
|
||||
import ai.openclaw.android.BuildConfig
|
||||
import ai.openclaw.android.SecurePrefs
|
||||
import ai.openclaw.android.gateway.GatewayClientInfo
|
||||
import ai.openclaw.android.gateway.GatewayConnectOptions
|
||||
import ai.openclaw.android.gateway.GatewayEndpoint
|
||||
import ai.openclaw.android.gateway.GatewayTlsParams
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCapability
|
||||
import ai.openclaw.android.LocationMode
|
||||
import ai.openclaw.android.VoiceWakeMode
|
||||
|
||||
class ConnectionManager(
|
||||
private val prefs: SecurePrefs,
|
||||
private val cameraEnabled: () -> Boolean,
|
||||
private val locationMode: () -> LocationMode,
|
||||
private val voiceWakeMode: () -> VoiceWakeMode,
|
||||
private val smsAvailable: () -> Boolean,
|
||||
private val hasRecordAudioPermission: () -> Boolean,
|
||||
private val manualTls: () -> Boolean,
|
||||
) {
|
||||
fun buildInvokeCommands(): List<String> =
|
||||
buildList {
|
||||
add(OpenClawCanvasCommand.Present.rawValue)
|
||||
add(OpenClawCanvasCommand.Hide.rawValue)
|
||||
add(OpenClawCanvasCommand.Navigate.rawValue)
|
||||
add(OpenClawCanvasCommand.Eval.rawValue)
|
||||
add(OpenClawCanvasCommand.Snapshot.rawValue)
|
||||
add(OpenClawCanvasA2UICommand.Push.rawValue)
|
||||
add(OpenClawCanvasA2UICommand.PushJSONL.rawValue)
|
||||
add(OpenClawCanvasA2UICommand.Reset.rawValue)
|
||||
add(OpenClawScreenCommand.Record.rawValue)
|
||||
if (cameraEnabled()) {
|
||||
add(OpenClawCameraCommand.Snap.rawValue)
|
||||
add(OpenClawCameraCommand.Clip.rawValue)
|
||||
}
|
||||
if (locationMode() != LocationMode.Off) {
|
||||
add(OpenClawLocationCommand.Get.rawValue)
|
||||
}
|
||||
if (smsAvailable()) {
|
||||
add(OpenClawSmsCommand.Send.rawValue)
|
||||
}
|
||||
if (BuildConfig.DEBUG) {
|
||||
add("debug.logs")
|
||||
add("debug.ed25519")
|
||||
}
|
||||
add("app.update")
|
||||
}
|
||||
|
||||
fun buildCapabilities(): List<String> =
|
||||
buildList {
|
||||
add(OpenClawCapability.Canvas.rawValue)
|
||||
add(OpenClawCapability.Screen.rawValue)
|
||||
if (cameraEnabled()) add(OpenClawCapability.Camera.rawValue)
|
||||
if (smsAvailable()) add(OpenClawCapability.Sms.rawValue)
|
||||
if (voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission()) {
|
||||
add(OpenClawCapability.VoiceWake.rawValue)
|
||||
}
|
||||
if (locationMode() != LocationMode.Off) {
|
||||
add(OpenClawCapability.Location.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
fun resolvedVersionName(): String {
|
||||
val versionName = BuildConfig.VERSION_NAME.trim().ifEmpty { "dev" }
|
||||
return if (BuildConfig.DEBUG && !versionName.contains("dev", ignoreCase = true)) {
|
||||
"$versionName-dev"
|
||||
} else {
|
||||
versionName
|
||||
}
|
||||
}
|
||||
|
||||
fun resolveModelIdentifier(): String? {
|
||||
return listOfNotNull(Build.MANUFACTURER, Build.MODEL)
|
||||
.joinToString(" ")
|
||||
.trim()
|
||||
.ifEmpty { null }
|
||||
}
|
||||
|
||||
fun buildUserAgent(): String {
|
||||
val version = resolvedVersionName()
|
||||
val release = Build.VERSION.RELEASE?.trim().orEmpty()
|
||||
val releaseLabel = if (release.isEmpty()) "unknown" else release
|
||||
return "OpenClawAndroid/$version (Android $releaseLabel; SDK ${Build.VERSION.SDK_INT})"
|
||||
}
|
||||
|
||||
fun buildClientInfo(clientId: String, clientMode: String): GatewayClientInfo {
|
||||
return GatewayClientInfo(
|
||||
id = clientId,
|
||||
displayName = prefs.displayName.value,
|
||||
version = resolvedVersionName(),
|
||||
platform = "android",
|
||||
mode = clientMode,
|
||||
instanceId = prefs.instanceId.value,
|
||||
deviceFamily = "Android",
|
||||
modelIdentifier = resolveModelIdentifier(),
|
||||
)
|
||||
}
|
||||
|
||||
fun buildNodeConnectOptions(): GatewayConnectOptions {
|
||||
return GatewayConnectOptions(
|
||||
role = "node",
|
||||
scopes = emptyList(),
|
||||
caps = buildCapabilities(),
|
||||
commands = buildInvokeCommands(),
|
||||
permissions = emptyMap(),
|
||||
client = buildClientInfo(clientId = "openclaw-android", clientMode = "node"),
|
||||
userAgent = buildUserAgent(),
|
||||
)
|
||||
}
|
||||
|
||||
fun buildOperatorConnectOptions(): GatewayConnectOptions {
|
||||
return GatewayConnectOptions(
|
||||
role = "operator",
|
||||
scopes = listOf("operator.read", "operator.write", "operator.talk.secrets"),
|
||||
caps = emptyList(),
|
||||
commands = emptyList(),
|
||||
permissions = emptyMap(),
|
||||
client = buildClientInfo(clientId = "openclaw-control-ui", clientMode = "ui"),
|
||||
userAgent = buildUserAgent(),
|
||||
)
|
||||
}
|
||||
|
||||
fun resolveTlsParams(endpoint: GatewayEndpoint): GatewayTlsParams? {
|
||||
val stored = prefs.loadGatewayTlsFingerprint(endpoint.stableId)
|
||||
val hinted = endpoint.tlsEnabled || !endpoint.tlsFingerprintSha256.isNullOrBlank()
|
||||
val manual = endpoint.stableId.startsWith("manual|")
|
||||
|
||||
if (manual) {
|
||||
if (!manualTls()) return null
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
||||
allowTOFU = stored == null,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
if (hinted) {
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = endpoint.tlsFingerprintSha256 ?: stored,
|
||||
allowTOFU = stored == null,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
if (!stored.isNullOrBlank()) {
|
||||
return GatewayTlsParams(
|
||||
required = true,
|
||||
expectedFingerprint = stored,
|
||||
allowTOFU = false,
|
||||
stableId = endpoint.stableId,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.content.Context
|
||||
import ai.openclaw.android.BuildConfig
|
||||
import ai.openclaw.android.gateway.DeviceIdentityStore
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
class DebugHandler(
|
||||
private val appContext: Context,
|
||||
private val identityStore: DeviceIdentityStore,
|
||||
) {
|
||||
|
||||
fun handleEd25519(): GatewaySession.InvokeResult {
|
||||
if (!BuildConfig.DEBUG) {
|
||||
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = "debug commands are disabled in release builds")
|
||||
}
|
||||
// Self-test Ed25519 signing and return diagnostic info
|
||||
try {
|
||||
val identity = identityStore.loadOrCreate()
|
||||
val testPayload = "test|${identity.deviceId}|${System.currentTimeMillis()}"
|
||||
val results = mutableListOf<String>()
|
||||
results.add("deviceId: ${identity.deviceId}")
|
||||
results.add("publicKeyRawBase64: ${identity.publicKeyRawBase64.take(20)}...")
|
||||
results.add("privateKeyPkcs8Base64: ${identity.privateKeyPkcs8Base64.take(20)}...")
|
||||
|
||||
// Test publicKeyBase64Url
|
||||
val pubKeyUrl = identityStore.publicKeyBase64Url(identity)
|
||||
results.add("publicKeyBase64Url: ${pubKeyUrl ?: "NULL (FAILED)"}")
|
||||
|
||||
// Test signing
|
||||
val signature = identityStore.signPayload(testPayload, identity)
|
||||
results.add("signPayload: ${if (signature != null) "${signature.take(20)}... (OK)" else "NULL (FAILED)"}")
|
||||
|
||||
// Test self-verify
|
||||
if (signature != null) {
|
||||
val verifyOk = identityStore.verifySelfSignature(testPayload, signature, identity)
|
||||
results.add("verifySelfSignature: $verifyOk")
|
||||
}
|
||||
|
||||
// Check available providers
|
||||
val providers = java.security.Security.getProviders()
|
||||
val ed25519Providers = providers.filter { p ->
|
||||
p.services.any { s -> s.algorithm.contains("Ed25519", ignoreCase = true) }
|
||||
}
|
||||
results.add("Ed25519 providers: ${ed25519Providers.map { "${it.name} v${it.version}" }}")
|
||||
results.add("Provider order: ${providers.take(5).map { it.name }}")
|
||||
|
||||
// Test KeyFactory directly
|
||||
try {
|
||||
val kf = java.security.KeyFactory.getInstance("Ed25519")
|
||||
results.add("KeyFactory.Ed25519: ${kf.provider.name} (OK)")
|
||||
} catch (e: Throwable) {
|
||||
results.add("KeyFactory.Ed25519: FAILED - ${e.javaClass.simpleName}: ${e.message}")
|
||||
}
|
||||
|
||||
// Test Signature directly
|
||||
try {
|
||||
val sig = java.security.Signature.getInstance("Ed25519")
|
||||
results.add("Signature.Ed25519: ${sig.provider.name} (OK)")
|
||||
} catch (e: Throwable) {
|
||||
results.add("Signature.Ed25519: FAILED - ${e.javaClass.simpleName}: ${e.message}")
|
||||
}
|
||||
|
||||
return GatewaySession.InvokeResult.ok("""{"diagnostics":"${results.joinToString("\\n").replace("\"", "\\\"")}"}"""")
|
||||
} catch (e: Throwable) {
|
||||
return GatewaySession.InvokeResult.error(code = "ED25519_TEST_FAILED", message = "${e.javaClass.simpleName}: ${e.message}\n${e.stackTraceToString().take(500)}")
|
||||
}
|
||||
}
|
||||
|
||||
fun handleLogs(): GatewaySession.InvokeResult {
|
||||
if (!BuildConfig.DEBUG) {
|
||||
return GatewaySession.InvokeResult.error(code = "UNAVAILABLE", message = "debug commands are disabled in release builds")
|
||||
}
|
||||
val pid = android.os.Process.myPid()
|
||||
val rt = Runtime.getRuntime()
|
||||
val info = "v6 pid=$pid thread=${Thread.currentThread().name} free=${rt.freeMemory()/1024}K total=${rt.totalMemory()/1024}K max=${rt.maxMemory()/1024}K uptime=${android.os.SystemClock.elapsedRealtime()/1000}s sdk=${android.os.Build.VERSION.SDK_INT} device=${android.os.Build.MODEL}\n"
|
||||
// Run logcat on current dispatcher thread (no withContext) with file redirect
|
||||
val logResult = try {
|
||||
val tmpFile = java.io.File(appContext.cacheDir, "debug_logs.txt")
|
||||
if (tmpFile.exists()) tmpFile.delete()
|
||||
val pb = ProcessBuilder("logcat", "-d", "-t", "200", "--pid=$pid")
|
||||
pb.redirectOutput(tmpFile)
|
||||
pb.redirectErrorStream(true)
|
||||
val proc = pb.start()
|
||||
val finished = proc.waitFor(4, java.util.concurrent.TimeUnit.SECONDS)
|
||||
if (!finished) proc.destroyForcibly()
|
||||
val raw = if (tmpFile.exists() && tmpFile.length() > 0) {
|
||||
tmpFile.readText().take(128000)
|
||||
} else {
|
||||
"(no output, finished=$finished, exists=${tmpFile.exists()})"
|
||||
}
|
||||
tmpFile.delete()
|
||||
val spamPatterns = listOf("setRequestedFrameRate", "I View :", "BLASTBufferQueue", "VRI[Pop-Up",
|
||||
"InsetsController:", "VRI[MainActivity", "InsetsSource:", "handleResized", "ProfileInstaller",
|
||||
"I VRI[", "onStateChanged: host=", "D StrictMode:", "E StrictMode:", "ImeFocusController",
|
||||
"InputTransport", "IncorrectContextUseViolation")
|
||||
val sb = StringBuilder()
|
||||
for (line in raw.lineSequence()) {
|
||||
if (line.isBlank()) continue
|
||||
if (spamPatterns.any { line.contains(it) }) continue
|
||||
if (sb.length + line.length > 16000) { sb.append("\n(truncated)"); break }
|
||||
if (sb.isNotEmpty()) sb.append('\n')
|
||||
sb.append(line)
|
||||
}
|
||||
sb.toString().ifEmpty { "(all ${raw.lines().size} lines filtered as spam)" }
|
||||
} catch (e: Throwable) {
|
||||
"(logcat error: ${e::class.java.simpleName}: ${e.message})"
|
||||
}
|
||||
// Also include camera debug log if it exists
|
||||
val camLogFile = java.io.File(appContext.cacheDir, "camera_debug.log")
|
||||
val camLog = if (camLogFile.exists() && camLogFile.length() > 0) {
|
||||
"\n--- camera_debug.log ---\n" + camLogFile.readText().take(4000)
|
||||
} else ""
|
||||
return GatewaySession.InvokeResult.ok("""{"logs":${JsonPrimitive(info + logResult + camLog)}}""")
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.SecurePrefs
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
|
||||
class GatewayEventHandler(
|
||||
private val scope: CoroutineScope,
|
||||
private val prefs: SecurePrefs,
|
||||
private val json: Json,
|
||||
private val operatorSession: GatewaySession,
|
||||
private val isConnected: () -> Boolean,
|
||||
) {
|
||||
private var suppressWakeWordsSync = false
|
||||
private var wakeWordsSyncJob: Job? = null
|
||||
|
||||
fun applyWakeWordsFromGateway(words: List<String>) {
|
||||
suppressWakeWordsSync = true
|
||||
prefs.setWakeWords(words)
|
||||
suppressWakeWordsSync = false
|
||||
}
|
||||
|
||||
fun scheduleWakeWordsSyncIfNeeded() {
|
||||
if (suppressWakeWordsSync) return
|
||||
if (!isConnected()) return
|
||||
|
||||
val snapshot = prefs.wakeWords.value
|
||||
wakeWordsSyncJob?.cancel()
|
||||
wakeWordsSyncJob =
|
||||
scope.launch {
|
||||
delay(650)
|
||||
val jsonList = snapshot.joinToString(separator = ",") { it.toJsonString() }
|
||||
val params = """{"triggers":[$jsonList]}"""
|
||||
try {
|
||||
operatorSession.request("voicewake.set", params)
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun refreshWakeWordsFromGateway() {
|
||||
if (!isConnected()) return
|
||||
try {
|
||||
val res = operatorSession.request("voicewake.get", "{}")
|
||||
val payload = json.parseToJsonElement(res).asObjectOrNull() ?: return
|
||||
val array = payload["triggers"] as? JsonArray ?: return
|
||||
val triggers = array.mapNotNull { it.asStringOrNull() }
|
||||
applyWakeWordsFromGateway(triggers)
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
fun handleVoiceWakeChangedEvent(payloadJson: String?) {
|
||||
if (payloadJson.isNullOrBlank()) return
|
||||
try {
|
||||
val payload = json.parseToJsonElement(payloadJson).asObjectOrNull() ?: return
|
||||
val array = payload["triggers"] as? JsonArray ?: return
|
||||
val triggers = array.mapNotNull { it.asStringOrNull() }
|
||||
applyWakeWordsFromGateway(triggers)
|
||||
} catch (_: Throwable) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasA2UICommand
|
||||
import ai.openclaw.android.protocol.OpenClawCanvasCommand
|
||||
import ai.openclaw.android.protocol.OpenClawCameraCommand
|
||||
import ai.openclaw.android.protocol.OpenClawLocationCommand
|
||||
import ai.openclaw.android.protocol.OpenClawScreenCommand
|
||||
import ai.openclaw.android.protocol.OpenClawSmsCommand
|
||||
|
||||
class InvokeDispatcher(
|
||||
private val canvas: CanvasController,
|
||||
private val cameraHandler: CameraHandler,
|
||||
private val locationHandler: LocationHandler,
|
||||
private val screenHandler: ScreenHandler,
|
||||
private val smsHandler: SmsHandler,
|
||||
private val a2uiHandler: A2UIHandler,
|
||||
private val debugHandler: DebugHandler,
|
||||
private val appUpdateHandler: AppUpdateHandler,
|
||||
private val isForeground: () -> Boolean,
|
||||
private val cameraEnabled: () -> Boolean,
|
||||
private val locationEnabled: () -> Boolean,
|
||||
) {
|
||||
suspend fun handleInvoke(command: String, paramsJson: String?): GatewaySession.InvokeResult {
|
||||
// Check foreground requirement for canvas/camera/screen commands
|
||||
if (
|
||||
command.startsWith(OpenClawCanvasCommand.NamespacePrefix) ||
|
||||
command.startsWith(OpenClawCanvasA2UICommand.NamespacePrefix) ||
|
||||
command.startsWith(OpenClawCameraCommand.NamespacePrefix) ||
|
||||
command.startsWith(OpenClawScreenCommand.NamespacePrefix)
|
||||
) {
|
||||
if (!isForeground()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas/camera/screen commands require foreground",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Check camera enabled
|
||||
if (command.startsWith(OpenClawCameraCommand.NamespacePrefix) && !cameraEnabled()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "CAMERA_DISABLED",
|
||||
message = "CAMERA_DISABLED: enable Camera in Settings",
|
||||
)
|
||||
}
|
||||
|
||||
// Check location enabled
|
||||
if (command.startsWith(OpenClawLocationCommand.NamespacePrefix) && !locationEnabled()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_DISABLED",
|
||||
message = "LOCATION_DISABLED: enable Location in Settings",
|
||||
)
|
||||
}
|
||||
|
||||
return when (command) {
|
||||
// Canvas commands
|
||||
OpenClawCanvasCommand.Present.rawValue -> {
|
||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||
canvas.navigate(url)
|
||||
GatewaySession.InvokeResult.ok(null)
|
||||
}
|
||||
OpenClawCanvasCommand.Hide.rawValue -> GatewaySession.InvokeResult.ok(null)
|
||||
OpenClawCanvasCommand.Navigate.rawValue -> {
|
||||
val url = CanvasController.parseNavigateUrl(paramsJson)
|
||||
canvas.navigate(url)
|
||||
GatewaySession.InvokeResult.ok(null)
|
||||
}
|
||||
OpenClawCanvasCommand.Eval.rawValue -> {
|
||||
val js =
|
||||
CanvasController.parseEvalJs(paramsJson)
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: javaScript required",
|
||||
)
|
||||
val result =
|
||||
try {
|
||||
canvas.eval(js)
|
||||
} catch (err: Throwable) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
GatewaySession.InvokeResult.ok("""{"result":${result.toJsonString()}}""")
|
||||
}
|
||||
OpenClawCanvasCommand.Snapshot.rawValue -> {
|
||||
val snapshotParams = CanvasController.parseSnapshotParams(paramsJson)
|
||||
val base64 =
|
||||
try {
|
||||
canvas.snapshotBase64(
|
||||
format = snapshotParams.format,
|
||||
quality = snapshotParams.quality,
|
||||
maxWidth = snapshotParams.maxWidth,
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "NODE_BACKGROUND_UNAVAILABLE",
|
||||
message = "NODE_BACKGROUND_UNAVAILABLE: canvas unavailable",
|
||||
)
|
||||
}
|
||||
GatewaySession.InvokeResult.ok("""{"format":"${snapshotParams.format.rawValue}","base64":"$base64"}""")
|
||||
}
|
||||
|
||||
// A2UI commands
|
||||
OpenClawCanvasA2UICommand.Reset.rawValue -> {
|
||||
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
val res = canvas.eval(A2UIHandler.a2uiResetJS)
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
OpenClawCanvasA2UICommand.Push.rawValue, OpenClawCanvasA2UICommand.PushJSONL.rawValue -> {
|
||||
val messages =
|
||||
try {
|
||||
a2uiHandler.decodeA2uiMessages(command, paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = err.message ?: "invalid A2UI payload"
|
||||
)
|
||||
}
|
||||
val a2uiUrl = a2uiHandler.resolveA2uiHostUrl()
|
||||
?: return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_NOT_CONFIGURED",
|
||||
message = "A2UI_HOST_NOT_CONFIGURED: gateway did not advertise canvas host",
|
||||
)
|
||||
val ready = a2uiHandler.ensureA2uiReady(a2uiUrl)
|
||||
if (!ready) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "A2UI_HOST_UNAVAILABLE",
|
||||
message = "A2UI host not reachable",
|
||||
)
|
||||
}
|
||||
val js = A2UIHandler.a2uiApplyMessagesJS(messages)
|
||||
val res = canvas.eval(js)
|
||||
GatewaySession.InvokeResult.ok(res)
|
||||
}
|
||||
|
||||
// Camera commands
|
||||
OpenClawCameraCommand.Snap.rawValue -> cameraHandler.handleSnap(paramsJson)
|
||||
OpenClawCameraCommand.Clip.rawValue -> cameraHandler.handleClip(paramsJson)
|
||||
|
||||
// Location command
|
||||
OpenClawLocationCommand.Get.rawValue -> locationHandler.handleLocationGet(paramsJson)
|
||||
|
||||
// Screen command
|
||||
OpenClawScreenCommand.Record.rawValue -> screenHandler.handleScreenRecord(paramsJson)
|
||||
|
||||
// SMS command
|
||||
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
|
||||
|
||||
// Debug commands
|
||||
"debug.ed25519" -> debugHandler.handleEd25519()
|
||||
"debug.logs" -> debugHandler.handleLogs()
|
||||
|
||||
// App update
|
||||
"app.update" -> appUpdateHandler.handleUpdate(paramsJson)
|
||||
|
||||
else ->
|
||||
GatewaySession.InvokeResult.error(
|
||||
code = "INVALID_REQUEST",
|
||||
message = "INVALID_REQUEST: unknown command",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.LocationManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.android.LocationMode
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
class LocationHandler(
|
||||
private val appContext: Context,
|
||||
private val location: LocationCaptureManager,
|
||||
private val json: Json,
|
||||
private val isForeground: () -> Boolean,
|
||||
private val locationMode: () -> LocationMode,
|
||||
private val locationPreciseEnabled: () -> Boolean,
|
||||
) {
|
||||
fun hasFineLocationPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
fun hasCoarseLocationPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
fun hasBackgroundLocationPermission(): Boolean {
|
||||
return (
|
||||
ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_BACKGROUND_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun handleLocationGet(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val mode = locationMode()
|
||||
if (!isForeground() && mode != LocationMode.Always) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_BACKGROUND_UNAVAILABLE",
|
||||
message = "LOCATION_BACKGROUND_UNAVAILABLE: background location requires Always",
|
||||
)
|
||||
}
|
||||
if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_PERMISSION_REQUIRED",
|
||||
message = "LOCATION_PERMISSION_REQUIRED: grant Location permission",
|
||||
)
|
||||
}
|
||||
if (!isForeground() && mode == LocationMode.Always && !hasBackgroundLocationPermission()) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_PERMISSION_REQUIRED",
|
||||
message = "LOCATION_PERMISSION_REQUIRED: enable Always in system Settings",
|
||||
)
|
||||
}
|
||||
val (maxAgeMs, timeoutMs, desiredAccuracy) = parseLocationParams(paramsJson)
|
||||
val preciseEnabled = locationPreciseEnabled()
|
||||
val accuracy =
|
||||
when (desiredAccuracy) {
|
||||
"precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
|
||||
"coarse" -> "coarse"
|
||||
else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced"
|
||||
}
|
||||
val providers =
|
||||
when (accuracy) {
|
||||
"precise" -> listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
|
||||
"coarse" -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
|
||||
else -> listOf(LocationManager.NETWORK_PROVIDER, LocationManager.GPS_PROVIDER)
|
||||
}
|
||||
try {
|
||||
val payload =
|
||||
location.getLocation(
|
||||
desiredProviders = providers,
|
||||
maxAgeMs = maxAgeMs,
|
||||
timeoutMs = timeoutMs,
|
||||
isPrecise = accuracy == "precise",
|
||||
)
|
||||
return GatewaySession.InvokeResult.ok(payload.payloadJson)
|
||||
} catch (err: TimeoutCancellationException) {
|
||||
return GatewaySession.InvokeResult.error(
|
||||
code = "LOCATION_TIMEOUT",
|
||||
message = "LOCATION_TIMEOUT: no fix in time",
|
||||
)
|
||||
} catch (err: Throwable) {
|
||||
val message = err.message ?: "LOCATION_UNAVAILABLE: no fix"
|
||||
return GatewaySession.InvokeResult.error(code = "LOCATION_UNAVAILABLE", message = message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseLocationParams(paramsJson: String?): Triple<Long?, Long, String?> {
|
||||
if (paramsJson.isNullOrBlank()) {
|
||||
return Triple(null, 10_000L, null)
|
||||
}
|
||||
val root =
|
||||
try {
|
||||
json.parseToJsonElement(paramsJson).asObjectOrNull()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
val maxAgeMs = (root?.get("maxAgeMs") as? JsonPrimitive)?.content?.toLongOrNull()
|
||||
val timeoutMs =
|
||||
(root?.get("timeoutMs") as? JsonPrimitive)?.content?.toLongOrNull()?.coerceIn(1_000L, 60_000L)
|
||||
?: 10_000L
|
||||
val desiredAccuracy =
|
||||
(root?.get("desiredAccuracy") as? JsonPrimitive)?.content?.trim()?.lowercase()
|
||||
return Triple(maxAgeMs, timeoutMs, desiredAccuracy)
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
const val DEFAULT_SEAM_COLOR_ARGB: Long = 0xFF4F7A9A
|
||||
|
||||
data class Quad<A, B, C, D>(val first: A, val second: B, val third: C, val fourth: D)
|
||||
|
||||
fun String.toJsonString(): String {
|
||||
val escaped =
|
||||
this.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
return "\"$escaped\""
|
||||
}
|
||||
|
||||
fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
|
||||
|
||||
fun JsonElement?.asStringOrNull(): String? =
|
||||
when (this) {
|
||||
is JsonNull -> null
|
||||
is JsonPrimitive -> content
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun parseHexColorArgb(raw: String?): Long? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return null
|
||||
val hex = if (trimmed.startsWith("#")) trimmed.drop(1) else trimmed
|
||||
if (hex.length != 6) return null
|
||||
val rgb = hex.toLongOrNull(16) ?: return null
|
||||
return 0xFF000000L or rgb
|
||||
}
|
||||
|
||||
fun invokeErrorFromThrowable(err: Throwable): Pair<String, String> {
|
||||
val raw = (err.message ?: "").trim()
|
||||
if (raw.isEmpty()) return "UNAVAILABLE" to "UNAVAILABLE: error"
|
||||
|
||||
val idx = raw.indexOf(':')
|
||||
if (idx <= 0) return "UNAVAILABLE" to raw
|
||||
val code = raw.substring(0, idx).trim().ifEmpty { "UNAVAILABLE" }
|
||||
val message = raw.substring(idx + 1).trim().ifEmpty { raw }
|
||||
return code to "$code: $message"
|
||||
}
|
||||
|
||||
fun normalizeMainKey(raw: String?): String? {
|
||||
val trimmed = raw?.trim().orEmpty()
|
||||
return if (trimmed.isEmpty()) null else trimmed
|
||||
}
|
||||
|
||||
fun isCanonicalMainSessionKey(key: String): Boolean {
|
||||
return key == "main"
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
|
||||
class ScreenHandler(
|
||||
private val screenRecorder: ScreenRecordManager,
|
||||
private val setScreenRecordActive: (Boolean) -> Unit,
|
||||
private val invokeErrorFromThrowable: (Throwable) -> Pair<String, String>,
|
||||
) {
|
||||
suspend fun handleScreenRecord(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
setScreenRecordActive(true)
|
||||
try {
|
||||
val res =
|
||||
try {
|
||||
screenRecorder.record(paramsJson)
|
||||
} catch (err: Throwable) {
|
||||
val (code, message) = invokeErrorFromThrowable(err)
|
||||
return GatewaySession.InvokeResult.error(code = code, message = message)
|
||||
}
|
||||
return GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||
} finally {
|
||||
setScreenRecordActive(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import ai.openclaw.android.gateway.GatewaySession
|
||||
|
||||
class SmsHandler(
|
||||
private val sms: SmsManager,
|
||||
) {
|
||||
suspend fun handleSmsSend(paramsJson: String?): GatewaySession.InvokeResult {
|
||||
val res = sms.send(paramsJson)
|
||||
if (res.ok) {
|
||||
return GatewaySession.InvokeResult.ok(res.payloadJson)
|
||||
} else {
|
||||
val error = res.error ?: "SMS_SEND_FAILED"
|
||||
val idx = error.indexOf(':')
|
||||
val code = if (idx > 0) error.substring(0, idx).trim() else "SMS_SEND_FAILED"
|
||||
return GatewaySession.InvokeResult.error(code = code, message = error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
val manualHost by viewModel.manualHost.collectAsState()
|
||||
val manualPort by viewModel.manualPort.collectAsState()
|
||||
val manualTls by viewModel.manualTls.collectAsState()
|
||||
val gatewayToken by viewModel.gatewayToken.collectAsState()
|
||||
val canvasDebugStatusEnabled by viewModel.canvasDebugStatusEnabled.collectAsState()
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val serverName by viewModel.serverName.collectAsState()
|
||||
@@ -404,14 +403,6 @@ fun SettingsSheet(viewModel: MainViewModel) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = manualEnabled,
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = gatewayToken,
|
||||
onValueChange = viewModel::setGatewayToken,
|
||||
label = { Text("Gateway Token") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = manualEnabled,
|
||||
singleLine = true,
|
||||
)
|
||||
ListItem(
|
||||
headlineContent = { Text("Require TLS") },
|
||||
supportingContent = { Text("Pin the gateway certificate on first connect.") },
|
||||
|
||||
@@ -37,7 +37,6 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ai.openclaw.android.chat.ChatSessionEntry
|
||||
|
||||
@@ -64,9 +63,8 @@ fun ChatComposer(
|
||||
var showSessionMenu by remember { mutableStateOf(false) }
|
||||
|
||||
val sessionOptions = resolveSessionChoices(sessionKey, sessions, mainSessionKey = mainSessionKey)
|
||||
val currentSessionLabel = friendlySessionName(
|
||||
val currentSessionLabel =
|
||||
sessionOptions.firstOrNull { it.key == sessionKey }?.displayName ?: sessionKey
|
||||
)
|
||||
|
||||
val canSend = pendingRunCount == 0 && (input.trim().isNotEmpty() || attachments.isNotEmpty()) && healthOk
|
||||
|
||||
@@ -78,7 +76,7 @@ fun ChatComposer(
|
||||
) {
|
||||
Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
@@ -87,13 +85,13 @@ fun ChatComposer(
|
||||
onClick = { showSessionMenu = true },
|
||||
contentPadding = ButtonDefaults.ContentPadding,
|
||||
) {
|
||||
Text(currentSessionLabel, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text("Session: $currentSessionLabel")
|
||||
}
|
||||
|
||||
DropdownMenu(expanded = showSessionMenu, onDismissRequest = { showSessionMenu = false }) {
|
||||
for (entry in sessionOptions) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(friendlySessionName(entry.displayName ?: entry.key)) },
|
||||
text = { Text(entry.displayName ?: entry.key) },
|
||||
onClick = {
|
||||
onSelectSession(entry.key)
|
||||
showSessionMenu = false
|
||||
@@ -115,7 +113,7 @@ fun ChatComposer(
|
||||
onClick = { showThinkingMenu = true },
|
||||
contentPadding = ButtonDefaults.ContentPadding,
|
||||
) {
|
||||
Text("🧠 ${thinkingLabel(thinkingLevel)}", maxLines = 1)
|
||||
Text("Thinking: ${thinkingLabel(thinkingLevel)}")
|
||||
}
|
||||
|
||||
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) {
|
||||
@@ -126,6 +124,8 @@ fun ChatComposer(
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
FilledTonalIconButton(onClick = onRefresh, modifier = Modifier.size(42.dp)) {
|
||||
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
|
||||
}
|
||||
|
||||
@@ -33,9 +33,14 @@ fun ChatMessageListCard(
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// With reverseLayout the newest item is at index 0 (bottom of screen).
|
||||
LaunchedEffect(messages.size, pendingRunCount, pendingToolCalls.size, streamingAssistantText) {
|
||||
listState.animateScrollToItem(index = 0)
|
||||
val total =
|
||||
messages.size +
|
||||
(if (pendingRunCount > 0) 1 else 0) +
|
||||
(if (pendingToolCalls.isNotEmpty()) 1 else 0) +
|
||||
(if (!streamingAssistantText.isNullOrBlank()) 1 else 0)
|
||||
if (total <= 0) return@LaunchedEffect
|
||||
listState.animateScrollToItem(index = total - 1)
|
||||
}
|
||||
|
||||
Card(
|
||||
@@ -51,17 +56,16 @@ fun ChatMessageListCard(
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 12.dp),
|
||||
) {
|
||||
// With reverseLayout = true, index 0 renders at the BOTTOM.
|
||||
// So we emit newest items first: streaming → tools → typing → messages (newest→oldest).
|
||||
items(count = messages.size, key = { idx -> messages[idx].id }) { idx ->
|
||||
ChatMessageBubble(message = messages[idx])
|
||||
}
|
||||
|
||||
val stream = streamingAssistantText?.trim()
|
||||
if (!stream.isNullOrEmpty()) {
|
||||
item(key = "stream") {
|
||||
ChatStreamingAssistantBubble(text = stream)
|
||||
if (pendingRunCount > 0) {
|
||||
item(key = "typing") {
|
||||
ChatTypingIndicatorBubble()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,15 +75,12 @@ fun ChatMessageListCard(
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingRunCount > 0) {
|
||||
item(key = "typing") {
|
||||
ChatTypingIndicatorBubble()
|
||||
val stream = streamingAssistantText?.trim()
|
||||
if (!stream.isNullOrEmpty()) {
|
||||
item(key = "stream") {
|
||||
ChatStreamingAssistantBubble(text = stream)
|
||||
}
|
||||
}
|
||||
|
||||
items(count = messages.size, key = { idx -> messages[messages.size - 1 - idx].id }) { idx ->
|
||||
ChatMessageBubble(message = messages[messages.size - 1 - idx])
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.isEmpty() && pendingRunCount == 0 && pendingToolCalls.isEmpty() && streamingAssistantText.isNullOrBlank()) {
|
||||
|
||||
@@ -43,17 +43,6 @@ import androidx.compose.ui.platform.LocalContext
|
||||
fun ChatMessageBubble(message: ChatMessage) {
|
||||
val isUser = message.role.lowercase() == "user"
|
||||
|
||||
// Filter to only displayable content parts (text with content, or base64 images)
|
||||
val displayableContent = message.content.filter { part ->
|
||||
when (part.type) {
|
||||
"text" -> !part.text.isNullOrBlank()
|
||||
else -> part.base64 != null
|
||||
}
|
||||
}
|
||||
|
||||
// Skip rendering entirely if no displayable content
|
||||
if (displayableContent.isEmpty()) return
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
|
||||
@@ -72,7 +61,7 @@ fun ChatMessageBubble(message: ChatMessage) {
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
) {
|
||||
val textColor = textColorOverBubble(isUser)
|
||||
ChatMessageBody(content = displayableContent, textColor = textColor)
|
||||
ChatMessageBody(content = message.content, textColor = textColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,30 +4,6 @@ import ai.openclaw.android.chat.ChatSessionEntry
|
||||
|
||||
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* Derive a human-friendly label from a raw session key.
|
||||
* Examples:
|
||||
* "telegram:g-agent-main-main" -> "Main"
|
||||
* "agent:main:main" -> "Main"
|
||||
* "discord:g-server-channel" -> "Server Channel"
|
||||
* "my-custom-session" -> "My Custom Session"
|
||||
*/
|
||||
fun friendlySessionName(key: String): String {
|
||||
// Strip common prefixes like "telegram:", "agent:", "discord:" etc.
|
||||
val stripped = key.substringAfterLast(":")
|
||||
|
||||
// Remove leading "g-" prefix (gateway artifact)
|
||||
val cleaned = if (stripped.startsWith("g-")) stripped.removePrefix("g-") else stripped
|
||||
|
||||
// Split on hyphens/underscores, title-case each word, collapse "main main" -> "Main"
|
||||
val words = cleaned.split('-', '_').filter { it.isNotBlank() }.map { word ->
|
||||
word.replaceFirstChar { it.uppercaseChar() }
|
||||
}.distinct()
|
||||
|
||||
val result = words.joinToString(" ")
|
||||
return result.ifBlank { key }
|
||||
}
|
||||
|
||||
fun resolveSessionChoices(
|
||||
currentSessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
|
||||
@@ -814,7 +814,7 @@ class TalkModeManager(
|
||||
val sagVoice = System.getenv("SAG_VOICE_ID")?.trim()
|
||||
val envKey = System.getenv("ELEVENLABS_API_KEY")?.trim()
|
||||
try {
|
||||
val res = session.request("talk.config", """{"includeSecrets":true}""")
|
||||
val res = session.request("config.get", "{}")
|
||||
val root = json.parseToJsonElement(res).asObjectOrNull()
|
||||
val config = root?.get("config").asObjectOrNull()
|
||||
val talk = config?.get("talk").asObjectOrNull()
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="apk_updates" path="updates/" />
|
||||
</paths>
|
||||
@@ -1,65 +0,0 @@
|
||||
package ai.openclaw.android.node
|
||||
|
||||
import java.io.File
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
||||
class AppUpdateHandlerTest {
|
||||
@Test
|
||||
fun parseAppUpdateRequest_acceptsHttpsWithMatchingHost() {
|
||||
val req =
|
||||
parseAppUpdateRequest(
|
||||
paramsJson =
|
||||
"""{"url":"https://gw.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""",
|
||||
connectedHost = "gw.example.com",
|
||||
)
|
||||
|
||||
assertEquals("https://gw.example.com/releases/openclaw.apk", req.url)
|
||||
assertEquals("a".repeat(64), req.expectedSha256)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseAppUpdateRequest_rejectsNonHttps() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
parseAppUpdateRequest(
|
||||
paramsJson = """{"url":"http://gw.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""",
|
||||
connectedHost = "gw.example.com",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseAppUpdateRequest_rejectsHostMismatch() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
parseAppUpdateRequest(
|
||||
paramsJson = """{"url":"https://evil.example.com/releases/openclaw.apk","sha256":"${"a".repeat(64)}"}""",
|
||||
connectedHost = "gw.example.com",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseAppUpdateRequest_rejectsInvalidSha256() {
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
parseAppUpdateRequest(
|
||||
paramsJson = """{"url":"https://gw.example.com/releases/openclaw.apk","sha256":"bad"}""",
|
||||
connectedHost = "gw.example.com",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sha256Hex_computesExpectedDigest() {
|
||||
val tmp = File.createTempFile("openclaw-update-hash", ".bin")
|
||||
try {
|
||||
tmp.writeText("hello", Charsets.UTF_8)
|
||||
assertEquals(
|
||||
"2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824",
|
||||
sha256Hex(tmp),
|
||||
)
|
||||
} finally {
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,4 +2,3 @@ org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 --enable-native-access=ALL-UNNAM
|
||||
org.gradle.warning.mode=none
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
android.enableR8.fullMode=true
|
||||
|
||||
@@ -1750,7 +1750,7 @@ private extension NodeAppModel {
|
||||
func makeOperatorConnectOptions(clientId: String, displayName: String?) -> GatewayConnectOptions {
|
||||
GatewayConnectOptions(
|
||||
role: "operator",
|
||||
scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
|
||||
scopes: ["operator.read", "operator.write", "operator.admin"],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
|
||||
@@ -1671,7 +1671,7 @@ extension TalkModeManager {
|
||||
func reloadConfig() async {
|
||||
guard let gateway else { return }
|
||||
do {
|
||||
let res = try await gateway.request(method: "talk.config", paramsJSON: "{\"includeSecrets\":true}", timeoutSeconds: 8)
|
||||
let res = try await gateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
|
||||
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
|
||||
guard let config = json["config"] as? [String: Any] else { return }
|
||||
let talk = config["talk"] as? [String: Any]
|
||||
|
||||
@@ -242,8 +242,6 @@ enum ExecApprovalsPromptPresenter {
|
||||
stack.orientation = .vertical
|
||||
stack.spacing = 8
|
||||
stack.alignment = .leading
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
stack.widthAnchor.constraint(greaterThanOrEqualToConstant: 380).isActive = true
|
||||
|
||||
let commandTitle = NSTextField(labelWithString: "Command")
|
||||
commandTitle.font = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)
|
||||
@@ -260,19 +258,16 @@ enum ExecApprovalsPromptPresenter {
|
||||
commandText.textContainer?.lineFragmentPadding = 0
|
||||
commandText.textContainer?.widthTracksTextView = true
|
||||
commandText.isHorizontallyResizable = false
|
||||
commandText.isVerticallyResizable = true
|
||||
commandText.isVerticallyResizable = false
|
||||
|
||||
let commandScroll = NSScrollView()
|
||||
commandScroll.borderType = .lineBorder
|
||||
commandScroll.hasVerticalScroller = true
|
||||
commandScroll.hasVerticalScroller = false
|
||||
commandScroll.hasHorizontalScroller = false
|
||||
commandScroll.autohidesScrollers = true
|
||||
commandScroll.documentView = commandText
|
||||
commandScroll.translatesAutoresizingMaskIntoConstraints = false
|
||||
commandScroll.widthAnchor.constraint(greaterThanOrEqualToConstant: 380).isActive = true
|
||||
commandScroll.widthAnchor.constraint(lessThanOrEqualToConstant: 440).isActive = true
|
||||
commandScroll.heightAnchor.constraint(greaterThanOrEqualToConstant: 56).isActive = true
|
||||
commandScroll.heightAnchor.constraint(lessThanOrEqualToConstant: 120).isActive = true
|
||||
stack.addArrangedSubview(commandScroll)
|
||||
|
||||
let contextTitle = NSTextField(labelWithString: "Context")
|
||||
|
||||
@@ -64,7 +64,6 @@ actor GatewayConnection {
|
||||
case wizardNext = "wizard.next"
|
||||
case wizardCancel = "wizard.cancel"
|
||||
case wizardStatus = "wizard.status"
|
||||
case talkConfig = "talk.config"
|
||||
case talkMode = "talk.mode"
|
||||
case webLoginStart = "web.login.start"
|
||||
case webLoginWait = "web.login.wait"
|
||||
|
||||
@@ -619,29 +619,7 @@ actor GatewayEndpointStore {
|
||||
}
|
||||
|
||||
extension GatewayEndpointStore {
|
||||
private static func normalizeDashboardPath(_ rawPath: String?) -> String {
|
||||
let trimmed = (rawPath ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return "/" }
|
||||
let withLeadingSlash = trimmed.hasPrefix("/") ? trimmed : "/" + trimmed
|
||||
guard withLeadingSlash != "/" else { return "/" }
|
||||
return withLeadingSlash.hasSuffix("/") ? withLeadingSlash : withLeadingSlash + "/"
|
||||
}
|
||||
|
||||
private static func localControlUiBasePath() -> String {
|
||||
let root = OpenClawConfigFile.loadDict()
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let controlUi = gateway["controlUi"] as? [String: Any]
|
||||
else {
|
||||
return "/"
|
||||
}
|
||||
return self.normalizeDashboardPath(controlUi["basePath"] as? String)
|
||||
}
|
||||
|
||||
static func dashboardURL(
|
||||
for config: GatewayConnection.Config,
|
||||
mode: AppState.ConnectionMode,
|
||||
localBasePath: String? = nil) throws -> URL
|
||||
{
|
||||
static func dashboardURL(for config: GatewayConnection.Config) throws -> URL {
|
||||
guard var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false) else {
|
||||
throw NSError(domain: "Dashboard", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Invalid gateway URL",
|
||||
@@ -655,17 +633,7 @@ extension GatewayEndpointStore {
|
||||
default:
|
||||
components.scheme = "http"
|
||||
}
|
||||
|
||||
let urlPath = self.normalizeDashboardPath(components.path)
|
||||
if urlPath != "/" {
|
||||
components.path = urlPath
|
||||
} else if mode == .local {
|
||||
let fallbackPath = localBasePath ?? self.localControlUiBasePath()
|
||||
components.path = self.normalizeDashboardPath(fallbackPath)
|
||||
} else {
|
||||
components.path = "/"
|
||||
}
|
||||
|
||||
components.path = "/"
|
||||
var queryItems: [URLQueryItem] = []
|
||||
if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!token.isEmpty
|
||||
|
||||
@@ -337,7 +337,7 @@ struct MenuContent: View {
|
||||
private func openDashboard() async {
|
||||
do {
|
||||
let config = try await GatewayEndpointStore.shared.requireConfig()
|
||||
let url = try GatewayEndpointStore.dashboardURL(for: config, mode: self.state.connectionMode)
|
||||
let url = try GatewayEndpointStore.dashboardURL(for: config)
|
||||
NSWorkspace.shared.open(url)
|
||||
} catch {
|
||||
let alert = NSAlert()
|
||||
|
||||
@@ -3,7 +3,6 @@ import Foundation
|
||||
|
||||
enum OpenClawConfigFile {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "config")
|
||||
private static let configAuditFileName = "config-audit.jsonl"
|
||||
|
||||
static func url() -> URL {
|
||||
OpenClawPaths.configURL
|
||||
@@ -36,61 +35,15 @@ enum OpenClawConfigFile {
|
||||
static func saveDict(_ dict: [String: Any]) {
|
||||
// Nix mode disables config writes in production, but tests rely on saving temp configs.
|
||||
if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return }
|
||||
let url = self.url()
|
||||
let previousData = try? Data(contentsOf: url)
|
||||
let previousRoot = previousData.flatMap { self.parseConfigData($0) }
|
||||
let previousBytes = previousData?.count
|
||||
let hadMetaBefore = self.hasMeta(previousRoot)
|
||||
let gatewayModeBefore = self.gatewayMode(previousRoot)
|
||||
|
||||
var output = dict
|
||||
self.stampMeta(&output)
|
||||
|
||||
do {
|
||||
let data = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys])
|
||||
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
|
||||
let url = self.url()
|
||||
try FileManager().createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
try data.write(to: url, options: [.atomic])
|
||||
let nextBytes = data.count
|
||||
let gatewayModeAfter = self.gatewayMode(output)
|
||||
let suspicious = self.configWriteSuspiciousReasons(
|
||||
existsBefore: previousData != nil,
|
||||
previousBytes: previousBytes,
|
||||
nextBytes: nextBytes,
|
||||
hadMetaBefore: hadMetaBefore,
|
||||
gatewayModeBefore: gatewayModeBefore,
|
||||
gatewayModeAfter: gatewayModeAfter)
|
||||
if !suspicious.isEmpty {
|
||||
self.logger.warning("config write anomaly (\(suspicious.joined(separator: ", "))) at \(url.path)")
|
||||
}
|
||||
self.appendConfigWriteAudit([
|
||||
"result": "success",
|
||||
"configPath": url.path,
|
||||
"existsBefore": previousData != nil,
|
||||
"previousBytes": previousBytes ?? NSNull(),
|
||||
"nextBytes": nextBytes,
|
||||
"hasMetaBefore": hadMetaBefore,
|
||||
"hasMetaAfter": self.hasMeta(output),
|
||||
"gatewayModeBefore": gatewayModeBefore ?? NSNull(),
|
||||
"gatewayModeAfter": gatewayModeAfter ?? NSNull(),
|
||||
"suspicious": suspicious,
|
||||
])
|
||||
} catch {
|
||||
self.logger.error("config save failed: \(error.localizedDescription)")
|
||||
self.appendConfigWriteAudit([
|
||||
"result": "failed",
|
||||
"configPath": url.path,
|
||||
"existsBefore": previousData != nil,
|
||||
"previousBytes": previousBytes ?? NSNull(),
|
||||
"nextBytes": NSNull(),
|
||||
"hasMetaBefore": hadMetaBefore,
|
||||
"hasMetaAfter": self.hasMeta(output),
|
||||
"gatewayModeBefore": gatewayModeBefore ?? NSNull(),
|
||||
"gatewayModeAfter": self.gatewayMode(output) ?? NSNull(),
|
||||
"suspicious": [],
|
||||
"error": error.localizedDescription,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,100 +214,4 @@ enum OpenClawConfigFile {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func stampMeta(_ root: inout [String: Any]) {
|
||||
var meta = root["meta"] as? [String: Any] ?? [:]
|
||||
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "macos-app"
|
||||
meta["lastTouchedVersion"] = version
|
||||
meta["lastTouchedAt"] = ISO8601DateFormatter().string(from: Date())
|
||||
root["meta"] = meta
|
||||
}
|
||||
|
||||
private static func hasMeta(_ root: [String: Any]?) -> Bool {
|
||||
guard let root else { return false }
|
||||
return root["meta"] is [String: Any]
|
||||
}
|
||||
|
||||
private static func hasMeta(_ root: [String: Any]) -> Bool {
|
||||
root["meta"] is [String: Any]
|
||||
}
|
||||
|
||||
private static func gatewayMode(_ root: [String: Any]?) -> String? {
|
||||
guard let root else { return nil }
|
||||
return self.gatewayMode(root)
|
||||
}
|
||||
|
||||
private static func gatewayMode(_ root: [String: Any]) -> String? {
|
||||
guard let gateway = root["gateway"] as? [String: Any],
|
||||
let mode = gateway["mode"] as? String
|
||||
else { return nil }
|
||||
let trimmed = mode.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func configWriteSuspiciousReasons(
|
||||
existsBefore: Bool,
|
||||
previousBytes: Int?,
|
||||
nextBytes: Int,
|
||||
hadMetaBefore: Bool,
|
||||
gatewayModeBefore: String?,
|
||||
gatewayModeAfter: String?) -> [String]
|
||||
{
|
||||
var reasons: [String] = []
|
||||
if !existsBefore {
|
||||
return reasons
|
||||
}
|
||||
if let previousBytes, previousBytes >= 512, nextBytes < max(1, previousBytes / 2) {
|
||||
reasons.append("size-drop:\(previousBytes)->\(nextBytes)")
|
||||
}
|
||||
if !hadMetaBefore {
|
||||
reasons.append("missing-meta-before-write")
|
||||
}
|
||||
if gatewayModeBefore != nil, gatewayModeAfter == nil {
|
||||
reasons.append("gateway-mode-removed")
|
||||
}
|
||||
return reasons
|
||||
}
|
||||
|
||||
private static func configAuditLogURL() -> URL {
|
||||
self.stateDirURL()
|
||||
.appendingPathComponent("logs", isDirectory: true)
|
||||
.appendingPathComponent(self.configAuditFileName, isDirectory: false)
|
||||
}
|
||||
|
||||
private static func appendConfigWriteAudit(_ fields: [String: Any]) {
|
||||
var record: [String: Any] = [
|
||||
"ts": ISO8601DateFormatter().string(from: Date()),
|
||||
"source": "macos-openclaw-config-file",
|
||||
"event": "config.write",
|
||||
"pid": ProcessInfo.processInfo.processIdentifier,
|
||||
"argv": Array(ProcessInfo.processInfo.arguments.prefix(8)),
|
||||
]
|
||||
for (key, value) in fields {
|
||||
record[key] = value is NSNull ? NSNull() : value
|
||||
}
|
||||
guard JSONSerialization.isValidJSONObject(record),
|
||||
let data = try? JSONSerialization.data(withJSONObject: record)
|
||||
else {
|
||||
return
|
||||
}
|
||||
var line = Data()
|
||||
line.append(data)
|
||||
line.append(0x0A)
|
||||
let logURL = self.configAuditLogURL()
|
||||
do {
|
||||
try FileManager().createDirectory(
|
||||
at: logURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true)
|
||||
if !FileManager().fileExists(atPath: logURL.path) {
|
||||
FileManager().createFile(atPath: logURL.path, contents: nil)
|
||||
}
|
||||
let handle = try FileHandle(forWritingTo: logURL)
|
||||
defer { try? handle.close() }
|
||||
try handle.seekToEnd()
|
||||
try handle.write(contentsOf: line)
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -800,8 +800,8 @@ extension TalkModeRuntime {
|
||||
|
||||
do {
|
||||
let snap: ConfigSnapshot = try await GatewayConnection.shared.requestDecoded(
|
||||
method: .talkConfig,
|
||||
params: ["includeSecrets": AnyCodable(true)],
|
||||
method: .configGet,
|
||||
params: nil,
|
||||
timeoutMs: 8000)
|
||||
let talk = snap.config?["talk"]?.dictionaryValue
|
||||
let ui = snap.config?["ui"]?.dictionaryValue
|
||||
|
||||
@@ -295,7 +295,6 @@ public struct Snapshot: Codable, Sendable {
|
||||
public let configpath: String?
|
||||
public let statedir: String?
|
||||
public let sessiondefaults: [String: AnyCodable]?
|
||||
public let authmode: AnyCodable?
|
||||
|
||||
public init(
|
||||
presence: [PresenceEntry],
|
||||
@@ -304,8 +303,7 @@ public struct Snapshot: Codable, Sendable {
|
||||
uptimems: Int,
|
||||
configpath: String?,
|
||||
statedir: String?,
|
||||
sessiondefaults: [String: AnyCodable]?,
|
||||
authmode: AnyCodable?
|
||||
sessiondefaults: [String: AnyCodable]?
|
||||
) {
|
||||
self.presence = presence
|
||||
self.health = health
|
||||
@@ -314,7 +312,6 @@ public struct Snapshot: Codable, Sendable {
|
||||
self.configpath = configpath
|
||||
self.statedir = statedir
|
||||
self.sessiondefaults = sessiondefaults
|
||||
self.authmode = authmode
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case presence
|
||||
@@ -324,7 +321,6 @@ public struct Snapshot: Codable, Sendable {
|
||||
case configpath = "configPath"
|
||||
case statedir = "stateDir"
|
||||
case sessiondefaults = "sessionDefaults"
|
||||
case authmode = "authMode"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1456,32 +1452,6 @@ public struct TalkModeParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct TalkConfigParams: Codable, Sendable {
|
||||
public let includesecrets: Bool?
|
||||
|
||||
public init(
|
||||
includesecrets: Bool?
|
||||
) {
|
||||
self.includesecrets = includesecrets
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case includesecrets = "includeSecrets"
|
||||
}
|
||||
}
|
||||
|
||||
public struct TalkConfigResult: Codable, Sendable {
|
||||
public let config: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
config: [String: AnyCodable]
|
||||
) {
|
||||
self.config = config
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case config
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChannelsStatusParams: Codable, Sendable {
|
||||
public let probe: Bool?
|
||||
public let timeoutms: Int?
|
||||
@@ -2384,7 +2354,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let resolvedpath: AnyCodable?
|
||||
public let sessionkey: AnyCodable?
|
||||
public let timeoutms: Int?
|
||||
public let twophase: Bool?
|
||||
|
||||
public init(
|
||||
id: String?,
|
||||
@@ -2396,8 +2365,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
agentid: AnyCodable?,
|
||||
resolvedpath: AnyCodable?,
|
||||
sessionkey: AnyCodable?,
|
||||
timeoutms: Int?,
|
||||
twophase: Bool?
|
||||
timeoutms: Int?
|
||||
) {
|
||||
self.id = id
|
||||
self.command = command
|
||||
@@ -2409,7 +2377,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
self.resolvedpath = resolvedpath
|
||||
self.sessionkey = sessionkey
|
||||
self.timeoutms = timeoutms
|
||||
self.twophase = twophase
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
@@ -2422,7 +2389,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
case resolvedpath = "resolvedPath"
|
||||
case sessionkey = "sessionKey"
|
||||
case timeoutms = "timeoutMs"
|
||||
case twophase = "twoPhase"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -176,48 +176,6 @@ import Testing
|
||||
#expect(host == "192.168.1.10")
|
||||
}
|
||||
|
||||
@Test func dashboardURLUsesLocalBasePathInLocalMode() throws {
|
||||
let config: GatewayConnection.Config = (
|
||||
url: try #require(URL(string: "ws://127.0.0.1:18789")),
|
||||
token: nil,
|
||||
password: nil
|
||||
)
|
||||
|
||||
let url = try GatewayEndpointStore.dashboardURL(
|
||||
for: config,
|
||||
mode: .local,
|
||||
localBasePath: " control ")
|
||||
#expect(url.absoluteString == "http://127.0.0.1:18789/control/")
|
||||
}
|
||||
|
||||
@Test func dashboardURLSkipsLocalBasePathInRemoteMode() throws {
|
||||
let config: GatewayConnection.Config = (
|
||||
url: try #require(URL(string: "ws://gateway.example:18789")),
|
||||
token: nil,
|
||||
password: nil
|
||||
)
|
||||
|
||||
let url = try GatewayEndpointStore.dashboardURL(
|
||||
for: config,
|
||||
mode: .remote,
|
||||
localBasePath: "/local-ui")
|
||||
#expect(url.absoluteString == "http://gateway.example:18789/")
|
||||
}
|
||||
|
||||
@Test func dashboardURLPrefersPathFromConfigURL() throws {
|
||||
let config: GatewayConnection.Config = (
|
||||
url: try #require(URL(string: "wss://gateway.example:443/remote-ui")),
|
||||
token: nil,
|
||||
password: nil
|
||||
)
|
||||
|
||||
let url = try GatewayEndpointStore.dashboardURL(
|
||||
for: config,
|
||||
mode: .remote,
|
||||
localBasePath: "/local-ui")
|
||||
#expect(url.absoluteString == "https://gateway.example:443/remote-ui/")
|
||||
}
|
||||
|
||||
@Test func normalizeGatewayUrlAddsDefaultPortForWs() {
|
||||
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway")
|
||||
#expect(url?.port == 18789)
|
||||
|
||||
@@ -76,43 +76,4 @@ struct OpenClawConfigFileTests {
|
||||
#expect(OpenClawConfigFile.url().path == "\(dir)/openclaw.json")
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test
|
||||
func saveDictAppendsConfigAuditLog() async throws {
|
||||
let stateDir = FileManager().temporaryDirectory
|
||||
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
|
||||
let configPath = stateDir.appendingPathComponent("openclaw.json")
|
||||
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
|
||||
|
||||
defer { try? FileManager().removeItem(at: stateDir) }
|
||||
|
||||
try await TestIsolation.withEnvValues([
|
||||
"OPENCLAW_STATE_DIR": stateDir.path,
|
||||
"OPENCLAW_CONFIG_PATH": configPath.path,
|
||||
]) {
|
||||
OpenClawConfigFile.saveDict([
|
||||
"gateway": ["mode": "local"],
|
||||
])
|
||||
|
||||
let configData = try Data(contentsOf: configPath)
|
||||
let configRoot = try JSONSerialization.jsonObject(with: configData) as? [String: Any]
|
||||
#expect((configRoot?["meta"] as? [String: Any]) != nil)
|
||||
|
||||
let rawAudit = try String(contentsOf: auditPath, encoding: .utf8)
|
||||
let lines = rawAudit
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map(String.init)
|
||||
#expect(!lines.isEmpty)
|
||||
guard let last = lines.last else {
|
||||
Issue.record("Missing config audit line")
|
||||
return
|
||||
}
|
||||
let auditRoot = try JSONSerialization.jsonObject(with: Data(last.utf8)) as? [String: Any]
|
||||
#expect(auditRoot?["source"] as? String == "macos-openclaw-config-file")
|
||||
#expect(auditRoot?["event"] as? String == "config.write")
|
||||
#expect(auditRoot?["result"] as? String == "success")
|
||||
#expect(auditRoot?["configPath"] as? String == configPath.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +295,6 @@ public struct Snapshot: Codable, Sendable {
|
||||
public let configpath: String?
|
||||
public let statedir: String?
|
||||
public let sessiondefaults: [String: AnyCodable]?
|
||||
public let authmode: AnyCodable?
|
||||
|
||||
public init(
|
||||
presence: [PresenceEntry],
|
||||
@@ -304,8 +303,7 @@ public struct Snapshot: Codable, Sendable {
|
||||
uptimems: Int,
|
||||
configpath: String?,
|
||||
statedir: String?,
|
||||
sessiondefaults: [String: AnyCodable]?,
|
||||
authmode: AnyCodable?
|
||||
sessiondefaults: [String: AnyCodable]?
|
||||
) {
|
||||
self.presence = presence
|
||||
self.health = health
|
||||
@@ -314,7 +312,6 @@ public struct Snapshot: Codable, Sendable {
|
||||
self.configpath = configpath
|
||||
self.statedir = statedir
|
||||
self.sessiondefaults = sessiondefaults
|
||||
self.authmode = authmode
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case presence
|
||||
@@ -324,7 +321,6 @@ public struct Snapshot: Codable, Sendable {
|
||||
case configpath = "configPath"
|
||||
case statedir = "stateDir"
|
||||
case sessiondefaults = "sessionDefaults"
|
||||
case authmode = "authMode"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1456,32 +1452,6 @@ public struct TalkModeParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct TalkConfigParams: Codable, Sendable {
|
||||
public let includesecrets: Bool?
|
||||
|
||||
public init(
|
||||
includesecrets: Bool?
|
||||
) {
|
||||
self.includesecrets = includesecrets
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case includesecrets = "includeSecrets"
|
||||
}
|
||||
}
|
||||
|
||||
public struct TalkConfigResult: Codable, Sendable {
|
||||
public let config: [String: AnyCodable]
|
||||
|
||||
public init(
|
||||
config: [String: AnyCodable]
|
||||
) {
|
||||
self.config = config
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case config
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChannelsStatusParams: Codable, Sendable {
|
||||
public let probe: Bool?
|
||||
public let timeoutms: Int?
|
||||
@@ -2384,7 +2354,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
public let resolvedpath: AnyCodable?
|
||||
public let sessionkey: AnyCodable?
|
||||
public let timeoutms: Int?
|
||||
public let twophase: Bool?
|
||||
|
||||
public init(
|
||||
id: String?,
|
||||
@@ -2396,8 +2365,7 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
agentid: AnyCodable?,
|
||||
resolvedpath: AnyCodable?,
|
||||
sessionkey: AnyCodable?,
|
||||
timeoutms: Int?,
|
||||
twophase: Bool?
|
||||
timeoutms: Int?
|
||||
) {
|
||||
self.id = id
|
||||
self.command = command
|
||||
@@ -2409,7 +2377,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
self.resolvedpath = resolvedpath
|
||||
self.sessionkey = sessionkey
|
||||
self.timeoutms = timeoutms
|
||||
self.twophase = twophase
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
@@ -2422,7 +2389,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
|
||||
case resolvedpath = "resolvedPath"
|
||||
case sessionkey = "sessionKey"
|
||||
case timeoutms = "timeoutMs"
|
||||
case twophase = "twoPhase"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,10 +41,9 @@ The hooks system allows you to:
|
||||
|
||||
### Bundled Hooks
|
||||
|
||||
OpenClaw ships with four bundled hooks that are automatically discovered:
|
||||
OpenClaw ships with three bundled hooks that are automatically discovered:
|
||||
|
||||
- **💾 session-memory**: Saves session context to your agent workspace (default `~/.openclaw/workspace/memory/`) when you issue `/new`
|
||||
- **📎 bootstrap-extra-files**: Injects additional workspace bootstrap files from configured glob/path patterns during `agent:bootstrap`
|
||||
- **📝 command-logger**: Logs all command events to `~/.openclaw/logs/commands.log`
|
||||
- **🚀 boot-md**: Runs `BOOT.md` when the gateway starts (requires internal hooks enabled)
|
||||
|
||||
@@ -128,7 +127,7 @@ The `HOOK.md` file contains metadata in YAML frontmatter plus Markdown documenta
|
||||
---
|
||||
name: my-hook
|
||||
description: "Short description of what this hook does"
|
||||
homepage: https://docs.openclaw.ai/automation/hooks#my-hook
|
||||
homepage: https://docs.openclaw.ai/hooks#my-hook
|
||||
metadata:
|
||||
{ "openclaw": { "emoji": "🔗", "events": ["command:new"], "requires": { "bins": ["node"] } } }
|
||||
---
|
||||
@@ -485,47 +484,6 @@ Saves session context to memory when you issue `/new`.
|
||||
openclaw hooks enable session-memory
|
||||
```
|
||||
|
||||
### bootstrap-extra-files
|
||||
|
||||
Injects additional bootstrap files (for example monorepo-local `AGENTS.md` / `TOOLS.md`) during `agent:bootstrap`.
|
||||
|
||||
**Events**: `agent:bootstrap`
|
||||
|
||||
**Requirements**: `workspace.dir` must be configured
|
||||
|
||||
**Output**: No files written; bootstrap context is modified in-memory only.
|
||||
|
||||
**Config**:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"internal": {
|
||||
"enabled": true,
|
||||
"entries": {
|
||||
"bootstrap-extra-files": {
|
||||
"enabled": true,
|
||||
"paths": ["packages/*/AGENTS.md", "packages/*/TOOLS.md"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Notes**:
|
||||
|
||||
- Paths are resolved relative to workspace.
|
||||
- Files must stay inside workspace (realpath-checked).
|
||||
- Only recognized bootstrap basenames are loaded.
|
||||
- Subagent allowlist is preserved (`AGENTS.md` and `TOOLS.md` only).
|
||||
|
||||
**Enable**:
|
||||
|
||||
```bash
|
||||
openclaw hooks enable bootstrap-extra-files
|
||||
```
|
||||
|
||||
### command-logger
|
||||
|
||||
Logs all command events to a centralized audit file.
|
||||
@@ -660,7 +618,6 @@ The gateway logs hook loading at startup:
|
||||
|
||||
```
|
||||
Registered hook: session-memory -> command:new
|
||||
Registered hook: bootstrap-extra-files -> agent:bootstrap
|
||||
Registered hook: command-logger -> command
|
||||
Registered hook: boot-md -> gateway:startup
|
||||
```
|
||||
|
||||
@@ -44,15 +44,11 @@ Examples:
|
||||
Routing picks **one agent** for each inbound message:
|
||||
|
||||
1. **Exact peer match** (`bindings` with `peer.kind` + `peer.id`).
|
||||
2. **Parent peer match** (thread inheritance).
|
||||
3. **Guild + roles match** (Discord) via `guildId` + `roles`.
|
||||
4. **Guild match** (Discord) via `guildId`.
|
||||
5. **Team match** (Slack) via `teamId`.
|
||||
6. **Account match** (`accountId` on the channel).
|
||||
7. **Channel match** (any account on that channel, `accountId: "*"`).
|
||||
8. **Default agent** (`agents.list[].default`, else first list entry, fallback to `main`).
|
||||
|
||||
When a binding includes multiple match fields (`peer`, `guildId`, `teamId`, `roles`), **all provided fields must match** for that binding to apply.
|
||||
2. **Guild match** (Discord) via `guildId`.
|
||||
3. **Team match** (Slack) via `teamId`.
|
||||
4. **Account match** (`accountId` on the channel).
|
||||
5. **Channel match** (any account on that channel).
|
||||
6. **Default agent** (`agents.list[].default`, else first list entry, fallback to `main`).
|
||||
|
||||
The matched agent determines which workspace and session store are used.
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ Token resolution is account-aware. Config token values win over env fallback. `D
|
||||
|
||||
### Role-based agent routing
|
||||
|
||||
Use `bindings[].match.roles` to route Discord guild members to different agents by role ID. Role-based bindings accept role IDs only and are evaluated after peer or parent-peer bindings and before guild-only bindings. If a binding also sets other match fields (for example `peer` + `guildId` + `roles`), all configured fields must match.
|
||||
Use `bindings[].match.roles` to route Discord guild members to different agents by role ID. Role-based bindings accept role IDs only and are evaluated after peer or parent-peer bindings and before guild-only bindings.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -273,8 +273,6 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior.
|
||||
- `first`
|
||||
- `all`
|
||||
|
||||
Note: `off` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored.
|
||||
|
||||
Message IDs are surfaced in context/history so agents can target specific messages.
|
||||
|
||||
</Accordion>
|
||||
@@ -332,37 +330,6 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Gateway proxy">
|
||||
Route Discord gateway WebSocket traffic through an HTTP(S) proxy with `channels.discord.proxy`.
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
proxy: "http://proxy.example:8080",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Per-account override:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
accounts: {
|
||||
primary: {
|
||||
proxy: "http://proxy.example:8080",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="PluralKit support">
|
||||
Enable PluralKit resolution to map proxied messages to system member identity:
|
||||
|
||||
@@ -388,59 +355,6 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Presence configuration">
|
||||
Presence updates are applied only when you set a status or activity field.
|
||||
|
||||
Status only example:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
status: "idle",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Activity example (custom status is the default activity type):
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
activity: "Focus time",
|
||||
activityType: 4,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Streaming example:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
discord: {
|
||||
activity: "Live coding",
|
||||
activityType: 1,
|
||||
activityUrl: "https://twitch.tv/openclaw",
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Activity type map:
|
||||
|
||||
- 0: Playing
|
||||
- 1: Streaming (requires `activityUrl`)
|
||||
- 2: Listening
|
||||
- 3: Watching
|
||||
- 4: Custom (uses the activity text as the status state; emoji is optional)
|
||||
- 5: Competing
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Exec approvals in Discord">
|
||||
Discord supports button-based exec approvals in DMs.
|
||||
|
||||
@@ -479,22 +393,6 @@ Default gate behavior:
|
||||
| moderation | disabled |
|
||||
| presence | disabled |
|
||||
|
||||
## Voice messages
|
||||
|
||||
Discord voice messages show a waveform preview and require OGG/Opus audio plus metadata. OpenClaw generates the waveform automatically, but it needs `ffmpeg` and `ffprobe` available on the gateway host to inspect and convert audio files.
|
||||
|
||||
Requirements and constraints:
|
||||
|
||||
- Provide a **local file path** (URLs are rejected).
|
||||
- Omit text content (Discord does not allow text + voice message in the same payload).
|
||||
- Any audio format is accepted; OpenClaw converts to OGG/Opus when needed.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
message(action="send", channel="discord", target="channel:123", path="/path/to/audio.mp3", asVoice=true)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<AccordionGroup>
|
||||
@@ -570,7 +468,6 @@ High-signal Discord fields:
|
||||
- delivery: `textChunkLimit`, `chunkMode`, `maxLinesPerMessage`
|
||||
- media/retry: `mediaMaxMb`, `retry`
|
||||
- actions: `actions.*`
|
||||
- presence: `activity`, `status`, `activityType`, `activityUrl`
|
||||
- features: `pluralkit`, `execApprovals`, `intents`, `agentComponents`, `heartbeat`, `responsePrefix`
|
||||
|
||||
## Safety and operations
|
||||
|
||||
@@ -20,7 +20,7 @@ title: grammY
|
||||
- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
|
||||
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` + `channels.telegram.webhookSecret` are set (otherwise it long-polls).
|
||||
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
|
||||
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`, `channels.telegram.webhookHost`.
|
||||
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
|
||||
- **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming.
|
||||
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
|
||||
|
||||
|
||||
@@ -136,47 +136,6 @@ When E2EE is enabled, the bot will request verification from your other sessions
|
||||
Open Element (or another client) and approve the verification request to establish trust.
|
||||
Once verified, the bot can decrypt messages in encrypted rooms.
|
||||
|
||||
## Multi-account
|
||||
|
||||
Multi-account support: use `channels.matrix.accounts` with per-account credentials and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||
|
||||
Each account runs as a separate Matrix user on any homeserver. Per-account config
|
||||
inherits from the top-level `channels.matrix` settings and can override any option
|
||||
(DM policy, groups, encryption, etc.).
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
matrix: {
|
||||
enabled: true,
|
||||
dm: { policy: "pairing" },
|
||||
accounts: {
|
||||
assistant: {
|
||||
name: "Main assistant",
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "syt_assistant_***",
|
||||
encryption: true,
|
||||
},
|
||||
alerts: {
|
||||
name: "Alerts bot",
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "syt_alerts_***",
|
||||
dm: { policy: "allowlist", allowFrom: ["@admin:example.org"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- Account startup is serialized to avoid race conditions with concurrent module imports.
|
||||
- Env variables (`MATRIX_HOMESERVER`, `MATRIX_ACCESS_TOKEN`, etc.) only apply to the **default** account.
|
||||
- Base channel settings (DM policy, group policy, mention gating, etc.) apply to all accounts unless overridden per account.
|
||||
- Use `bindings[].match.accountId` to route each account to a different agent.
|
||||
- Crypto state is stored per account + access token (separate key stores per account).
|
||||
|
||||
## Routing model
|
||||
|
||||
- Replies always go back to Matrix.
|
||||
@@ -297,5 +256,4 @@ Provider options:
|
||||
- `channels.matrix.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
- `channels.matrix.autoJoin`: invite handling (`always | allowlist | off`, default: always).
|
||||
- `channels.matrix.autoJoinAllowlist`: allowed room IDs/aliases for auto-join.
|
||||
- `channels.matrix.accounts`: multi-account configuration keyed by account ID (each account inherits top-level settings).
|
||||
- `channels.matrix.actions`: per-action tool gating (reactions/messages/pins/memberInfo/channelInfo).
|
||||
|
||||
@@ -423,8 +423,6 @@ If you need images/files in **channels** or want to fetch **message history**, y
|
||||
3. Bump the Teams app **manifest version**, re-upload, and **reinstall the app in Teams**.
|
||||
4. **Fully quit and relaunch Teams** to clear cached app metadata.
|
||||
|
||||
**Additional permission for user mentions:** User @mentions work out of the box for users in the conversation. However, if you want to dynamically search and mention users who are **not in the current conversation**, add `User.Read.All` (Application) permission and grant admin consent.
|
||||
|
||||
## Known Limitations
|
||||
|
||||
### Webhook timeouts
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Signal support via signal-cli (JSON-RPC + SSE), setup paths, and number model"
|
||||
summary: "Signal support via signal-cli (JSON-RPC + SSE), setup, and number model"
|
||||
read_when:
|
||||
- Setting up Signal support
|
||||
- Debugging Signal send/receive
|
||||
@@ -10,22 +10,13 @@ title: "Signal"
|
||||
|
||||
Status: external CLI integration. Gateway talks to `signal-cli` over HTTP JSON-RPC + SSE.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- OpenClaw installed on your server (Linux flow below tested on Ubuntu 24).
|
||||
- `signal-cli` available on the host where the gateway runs.
|
||||
- A phone number that can receive one verification SMS (for SMS registration path).
|
||||
- Browser access for Signal captcha (`signalcaptchas.org`) during registration.
|
||||
|
||||
## Quick setup (beginner)
|
||||
|
||||
1. Use a **separate Signal number** for the bot (recommended).
|
||||
2. Install `signal-cli` (Java required if you use the JVM build).
|
||||
3. Choose one setup path:
|
||||
- **Path A (QR link):** `signal-cli link -n "OpenClaw"` and scan with Signal.
|
||||
- **Path B (SMS register):** register a dedicated number with captcha + SMS verification.
|
||||
4. Configure OpenClaw and restart the gateway.
|
||||
5. Send a first DM and approve pairing (`openclaw pairing approve signal <CODE>`).
|
||||
2. Install `signal-cli` (Java required).
|
||||
3. Link the bot device and start the daemon:
|
||||
- `signal-cli link -n "OpenClaw"`
|
||||
4. Configure OpenClaw and start the gateway.
|
||||
|
||||
Minimal config:
|
||||
|
||||
@@ -43,15 +34,6 @@ Minimal config:
|
||||
}
|
||||
```
|
||||
|
||||
Field reference:
|
||||
|
||||
| Field | Description |
|
||||
| ----------- | ------------------------------------------------- |
|
||||
| `account` | Bot phone number in E.164 format (`+15551234567`) |
|
||||
| `cliPath` | Path to `signal-cli` (`signal-cli` if on `PATH`) |
|
||||
| `dmPolicy` | DM access policy (`pairing` recommended) |
|
||||
| `allowFrom` | Phone numbers or `uuid:<id>` values allowed to DM |
|
||||
|
||||
## What it is
|
||||
|
||||
- Signal channel via `signal-cli` (not embedded libsignal).
|
||||
@@ -76,9 +58,9 @@ Disable with:
|
||||
- If you run the bot on **your personal Signal account**, it will ignore your own messages (loop protection).
|
||||
- For "I text the bot and it replies," use a **separate bot number**.
|
||||
|
||||
## Setup path A: link existing Signal account (QR)
|
||||
## Setup (fast path)
|
||||
|
||||
1. Install `signal-cli` (JVM or native build).
|
||||
1. Install `signal-cli` (Java required).
|
||||
2. Link a bot account:
|
||||
- `signal-cli link -n "OpenClaw"` then scan the QR in Signal.
|
||||
3. Configure Signal and start the gateway.
|
||||
@@ -101,67 +83,6 @@ Example:
|
||||
|
||||
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||
|
||||
## Setup path B: register dedicated bot number (SMS, Linux)
|
||||
|
||||
Use this when you want a dedicated bot number instead of linking an existing Signal app account.
|
||||
|
||||
1. Get a number that can receive SMS (or voice verification for landlines).
|
||||
- Use a dedicated bot number to avoid account/session conflicts.
|
||||
2. Install `signal-cli` on the gateway host:
|
||||
|
||||
```bash
|
||||
VERSION=$(curl -Ls -o /dev/null -w %{url_effective} https://github.com/AsamK/signal-cli/releases/latest | sed -e 's/^.*\/v//')
|
||||
curl -L -O "https://github.com/AsamK/signal-cli/releases/download/v${VERSION}/signal-cli-${VERSION}-Linux-native.tar.gz"
|
||||
sudo tar xf "signal-cli-${VERSION}-Linux-native.tar.gz" -C /opt
|
||||
sudo ln -sf /opt/signal-cli /usr/local/bin/
|
||||
signal-cli --version
|
||||
```
|
||||
|
||||
If you use the JVM build (`signal-cli-${VERSION}.tar.gz`), install JRE 25+ first.
|
||||
Keep `signal-cli` updated; upstream notes that old releases can break as Signal server APIs change.
|
||||
|
||||
3. Register and verify the number:
|
||||
|
||||
```bash
|
||||
signal-cli -a +<BOT_PHONE_NUMBER> register
|
||||
```
|
||||
|
||||
If captcha is required:
|
||||
|
||||
1. Open `https://signalcaptchas.org/registration/generate.html`.
|
||||
2. Complete captcha, copy the `signalcaptcha://...` link target from "Open Signal".
|
||||
3. Run from the same external IP as the browser session when possible.
|
||||
4. Run registration again immediately (captcha tokens expire quickly):
|
||||
|
||||
```bash
|
||||
signal-cli -a +<BOT_PHONE_NUMBER> register --captcha '<SIGNALCAPTCHA_URL>'
|
||||
signal-cli -a +<BOT_PHONE_NUMBER> verify <VERIFICATION_CODE>
|
||||
```
|
||||
|
||||
4. Configure OpenClaw, restart gateway, verify channel:
|
||||
|
||||
```bash
|
||||
# If you run the gateway as a user systemd service:
|
||||
systemctl --user restart openclaw-gateway
|
||||
|
||||
# Then verify:
|
||||
openclaw doctor
|
||||
openclaw channels status --probe
|
||||
```
|
||||
|
||||
5. Pair your DM sender:
|
||||
- Send any message to the bot number.
|
||||
- Approve code on the server: `openclaw pairing approve signal <PAIRING_CODE>`.
|
||||
- Save the bot number as a contact on your phone to avoid "Unknown contact".
|
||||
|
||||
Important: registering a phone number account with `signal-cli` can de-authenticate the main Signal app session for that number. Prefer a dedicated bot number, or use QR link mode if you need to keep your existing phone app setup.
|
||||
|
||||
Upstream references:
|
||||
|
||||
- `signal-cli` README: `https://github.com/AsamK/signal-cli`
|
||||
- Captcha flow: `https://github.com/AsamK/signal-cli/wiki/Registration-with-captcha`
|
||||
- Linking flow: `https://github.com/AsamK/signal-cli/wiki/Linking-other-devices-(Provisioning)`
|
||||
|
||||
## External daemon mode (httpUrl)
|
||||
|
||||
If you want to manage `signal-cli` yourself (slow JVM cold starts, container init, or shared CPUs), run the daemon separately and point OpenClaw at it:
|
||||
@@ -270,26 +191,9 @@ Common failures:
|
||||
- Daemon reachable but no replies: verify account/daemon settings (`httpUrl`, `account`) and receive mode.
|
||||
- DMs ignored: sender is pending pairing approval.
|
||||
- Group messages ignored: group sender/mention gating blocks delivery.
|
||||
- Config validation errors after edits: run `openclaw doctor --fix`.
|
||||
- Signal missing from diagnostics: confirm `channels.signal.enabled: true`.
|
||||
|
||||
Extra checks:
|
||||
|
||||
```bash
|
||||
openclaw pairing list signal
|
||||
pgrep -af signal-cli
|
||||
grep -i "signal" "/tmp/openclaw/openclaw-$(date +%Y-%m-%d).log" | tail -20
|
||||
```
|
||||
|
||||
For triage flow: [/channels/troubleshooting](/channels/troubleshooting).
|
||||
|
||||
## Security notes
|
||||
|
||||
- `signal-cli` stores account keys locally (typically `~/.local/share/signal-cli/data/`).
|
||||
- Back up Signal account state before server migration or rebuild.
|
||||
- Keep `channels.signal.dmPolicy: "pairing"` unless you explicitly want broader DM access.
|
||||
- SMS verification is only needed for registration or recovery flows, but losing control of the number/account can complicate re-registration.
|
||||
|
||||
## Configuration reference (Signal)
|
||||
|
||||
Full configuration: [Configuration](/gateway/configuration)
|
||||
|
||||
@@ -220,7 +220,6 @@ and still route command execution against the target conversation session (`Comm
|
||||
- Channel sessions: `agent:<agentId>:slack:channel:<channelId>`.
|
||||
- Thread replies can create thread session suffixes (`:thread:<threadTs>`) when applicable.
|
||||
- `channels.slack.thread.historyScope` default is `thread`; `thread.inheritParent` default is `false`.
|
||||
- `channels.slack.thread.initialHistoryLimit` controls how many existing thread messages are fetched when a new thread session starts (default `20`; set `0` to disable).
|
||||
|
||||
Reply threading controls:
|
||||
|
||||
@@ -233,8 +232,6 @@ Manual reply tags are supported:
|
||||
- `[[reply_to_current]]`
|
||||
- `[[reply_to:<id>]]`
|
||||
|
||||
Note: `replyToMode="off"` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored.
|
||||
|
||||
## Media, chunking, and delivery
|
||||
|
||||
<AccordionGroup>
|
||||
|
||||
@@ -412,11 +412,9 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
|
||||
`channels.telegram.replyToMode` controls handling:
|
||||
|
||||
- `off` (default)
|
||||
- `first`
|
||||
- `first` (default)
|
||||
- `all`
|
||||
|
||||
Note: `off` disables implicit reply threading. Explicit `[[reply_to_*]]` tags are still honored.
|
||||
- `off`
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -597,12 +595,10 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- set `channels.telegram.webhookUrl`
|
||||
- set `channels.telegram.webhookSecret` (required when webhook URL is set)
|
||||
- optional `channels.telegram.webhookPath` (default `/telegram-webhook`)
|
||||
- optional `channels.telegram.webhookHost` (default `127.0.0.1`)
|
||||
|
||||
Default local listener for webhook mode binds to `127.0.0.1:8787`.
|
||||
Default local listener for webhook mode binds to `0.0.0.0:8787`.
|
||||
|
||||
If your public endpoint differs, place a reverse proxy in front and point `webhookUrl` at the public URL.
|
||||
Set `webhookHost` (for example `0.0.0.0`) when you intentionally need external ingress.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -677,45 +673,6 @@ More help: [Channel troubleshooting](/channels/troubleshooting).
|
||||
|
||||
Primary reference:
|
||||
|
||||
- `channels.telegram.enabled`: enable/disable channel startup.
|
||||
- `channels.telegram.botToken`: bot token (BotFather).
|
||||
- `channels.telegram.tokenFile`: read token from file path.
|
||||
- `channels.telegram.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.telegram.allowFrom`: DM allowlist (ids/usernames). `open` requires `"*"`.
|
||||
- `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.telegram.groupAllowFrom`: group sender allowlist (ids/usernames).
|
||||
- `channels.telegram.groups`: per-group defaults + allowlist (use `"*"` for global defaults).
|
||||
- `channels.telegram.groups.<id>.groupPolicy`: per-group override for groupPolicy (`open | allowlist | disabled`).
|
||||
- `channels.telegram.groups.<id>.requireMention`: mention gating default.
|
||||
- `channels.telegram.groups.<id>.skills`: skill filter (omit = all skills, empty = none).
|
||||
- `channels.telegram.groups.<id>.allowFrom`: per-group sender allowlist override.
|
||||
- `channels.telegram.groups.<id>.systemPrompt`: extra system prompt for the group.
|
||||
- `channels.telegram.groups.<id>.enabled`: disable the group when `false`.
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.*`: per-topic overrides (same fields as group).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.groupPolicy`: per-topic override for groupPolicy (`open | allowlist | disabled`).
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`: per-topic mention gating override.
|
||||
- `channels.telegram.capabilities.inlineButtons`: `off | dm | group | all | allowlist` (default: allowlist).
|
||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`: per-account override.
|
||||
- `channels.telegram.replyToMode`: `off | first | all` (default: `off`).
|
||||
- `channels.telegram.textChunkLimit`: outbound chunk size (chars).
|
||||
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
|
||||
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
|
||||
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
|
||||
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
|
||||
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
|
||||
- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to disabled on Node 22 to avoid Happy Eyeballs timeouts.
|
||||
- `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP).
|
||||
- `channels.telegram.webhookUrl`: enable webhook mode (requires `channels.telegram.webhookSecret`).
|
||||
- `channels.telegram.webhookSecret`: webhook secret (required when webhookUrl is set).
|
||||
- `channels.telegram.webhookPath`: local webhook path (default `/telegram-webhook`).
|
||||
- `channels.telegram.webhookHost`: local webhook bind host (default `127.0.0.1`).
|
||||
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
|
||||
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
|
||||
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
|
||||
- `channels.telegram.actions.sticker`: gate Telegram sticker actions — send and search (default: false).
|
||||
- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `own` when not set).
|
||||
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `minimal` when not set).
|
||||
|
||||
- [Configuration reference - Telegram](/gateway/configuration-reference#telegram)
|
||||
|
||||
Telegram-specific high-signal fields:
|
||||
@@ -727,7 +684,7 @@ Telegram-specific high-signal fields:
|
||||
- streaming: `streamMode`, `draftChunk`, `blockStreaming`
|
||||
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
|
||||
- media/network: `mediaMaxMb`, `timeoutSeconds`, `retry`, `network.autoSelectFamily`, `proxy`
|
||||
- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost`
|
||||
- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`
|
||||
- actions/capabilities: `capabilities.inlineButtons`, `actions.sendMessage|editMessage|deleteMessage|reactions|sticker`
|
||||
- reactions: `reactionNotifications`, `reactionLevel`
|
||||
- writes/history: `configWrites`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit`
|
||||
|
||||
@@ -32,11 +32,10 @@ List all discovered hooks from workspace, managed, and bundled directories.
|
||||
**Example output:**
|
||||
|
||||
```
|
||||
Hooks (4/4 ready)
|
||||
Hooks (3/3 ready)
|
||||
|
||||
Ready:
|
||||
🚀 boot-md ✓ - Run BOOT.md on gateway startup
|
||||
📎 bootstrap-extra-files ✓ - Inject extra workspace bootstrap files during agent bootstrap
|
||||
📝 command-logger ✓ - Log all command events to a centralized audit file
|
||||
💾 session-memory ✓ - Save session context to memory when /new command is issued
|
||||
```
|
||||
@@ -90,7 +89,7 @@ Details:
|
||||
Source: openclaw-bundled
|
||||
Path: /path/to/openclaw/hooks/bundled/session-memory/HOOK.md
|
||||
Handler: /path/to/openclaw/hooks/bundled/session-memory/handler.ts
|
||||
Homepage: https://docs.openclaw.ai/automation/hooks#session-memory
|
||||
Homepage: https://docs.openclaw.ai/hooks#session-memory
|
||||
Events: command:new
|
||||
|
||||
Requirements:
|
||||
@@ -250,18 +249,6 @@ openclaw hooks enable session-memory
|
||||
|
||||
**See:** [session-memory documentation](/automation/hooks#session-memory)
|
||||
|
||||
### bootstrap-extra-files
|
||||
|
||||
Injects additional bootstrap files (for example monorepo-local `AGENTS.md` / `TOOLS.md`) during `agent:bootstrap`.
|
||||
|
||||
**Enable:**
|
||||
|
||||
```bash
|
||||
openclaw hooks enable bootstrap-extra-files
|
||||
```
|
||||
|
||||
**See:** [bootstrap-extra-files documentation](/automation/hooks#bootstrap-extra-files)
|
||||
|
||||
### command-logger
|
||||
|
||||
Logs all command events to a centralized audit file.
|
||||
|
||||
@@ -25,4 +25,3 @@ openclaw security audit --fix
|
||||
The audit warns when multiple DM senders share the main session and recommends **secure DM mode**: `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes.
|
||||
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.
|
||||
For webhook ingress, it warns when `hooks.defaultSessionKey` is unset, when request `sessionKey` overrides are enabled, and when overrides are enabled without `hooks.allowedSessionKeyPrefixes`.
|
||||
It also warns when sandbox Docker settings are configured while sandbox mode is off, when `gateway.nodes.denyCommands` uses ineffective pattern-like/unknown entries, when global `tools.profile="minimal"` is overridden by agent tool profiles, and when installed extension plugin tools may be reachable under permissive tool policy.
|
||||
|
||||
@@ -21,7 +21,7 @@ Compaction **persists** in the session’s JSONL history.
|
||||
|
||||
## Configuration
|
||||
|
||||
Use the `agents.defaults.compaction` setting in your `openclaw.json` to configure compaction behavior (mode, target tokens, etc.).
|
||||
See [Compaction config & modes](/concepts/compaction) for the `agents.defaults.compaction` settings.
|
||||
|
||||
## Auto-compaction (default on)
|
||||
|
||||
|
||||
@@ -139,8 +139,8 @@ out to QMD for retrieval. Key points:
|
||||
- Boot refresh now runs in the background by default so chat startup is not
|
||||
blocked; set `memory.qmd.update.waitForBootSync = true` to keep the previous
|
||||
blocking behavior.
|
||||
- Searches run via `memory.qmd.searchMode` (default `qmd search --json`; also
|
||||
supports `vsearch` and `query`). If the selected mode rejects flags on your
|
||||
- Searches run via `memory.qmd.searchMode` (default `qmd query --json`; also
|
||||
supports `search` and `vsearch`). If the selected mode rejects flags on your
|
||||
QMD build, OpenClaw retries with `qmd query`. If QMD fails or the binary is
|
||||
missing, OpenClaw automatically falls back to the builtin SQLite manager so
|
||||
memory tools keep working.
|
||||
@@ -159,6 +159,10 @@ out to QMD for retrieval. Key points:
|
||||
```bash
|
||||
# Pick the same state dir OpenClaw uses
|
||||
STATE_DIR="${OPENCLAW_STATE_DIR:-$HOME/.openclaw}"
|
||||
if [ -d "$HOME/.moltbot" ] && [ ! -d "$HOME/.openclaw" ] \
|
||||
&& [ -z "${OPENCLAW_STATE_DIR:-}" ]; then
|
||||
STATE_DIR="$HOME/.moltbot"
|
||||
fi
|
||||
|
||||
export XDG_CONFIG_HOME="$STATE_DIR/agents/main/qmd/xdg-config"
|
||||
export XDG_CACHE_HOME="$STATE_DIR/agents/main/qmd/xdg-cache"
|
||||
@@ -174,8 +178,8 @@ out to QMD for retrieval. Key points:
|
||||
**Config surface (`memory.qmd.*`)**
|
||||
|
||||
- `command` (default `qmd`): override the executable path.
|
||||
- `searchMode` (default `search`): pick which QMD command backs
|
||||
`memory_search` (`search`, `vsearch`, `query`).
|
||||
- `searchMode` (default `query`): pick which QMD command backs
|
||||
`memory_search` (`query`, `search`, `vsearch`).
|
||||
- `includeDefaultMemory` (default `true`): auto-index `MEMORY.md` + `memory/**/*.md`.
|
||||
- `paths[]`: add extra directories/files (`path`, optional `pattern`, optional
|
||||
stable `name`).
|
||||
@@ -531,7 +535,7 @@ Notes:
|
||||
|
||||
### Local embedding auto-download
|
||||
|
||||
- Default local embedding model: `hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf` (~0.6 GB).
|
||||
- Default local embedding model: `hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf` (~0.6 GB).
|
||||
- When `memorySearch.provider = "local"`, `node-llama-cpp` resolves `modelPath`; if the GGUF is missing it **auto-downloads** to the cache (or `local.modelCacheDir` if set), then loads it. Downloads resume on retry.
|
||||
- Native build requirement: run `pnpm approve-builds`, pick `node-llama-cpp`, then `pnpm rebuild node-llama-cpp`.
|
||||
- Fallback: if local setup fails and `memorySearch.fallback = "openai"`, we automatically switch to remote embeddings (`openai/text-embedding-3-small` unless overridden) and record the reason.
|
||||
|
||||
@@ -120,7 +120,6 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
- OpenAI-compatible base URL: `https://api.cerebras.ai/v1`.
|
||||
- Mistral: `mistral` (`MISTRAL_API_KEY`)
|
||||
- GitHub Copilot: `github-copilot` (`COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN`)
|
||||
- Hugging Face Inference: `huggingface` (`HUGGINGFACE_HUB_TOKEN` or `HF_TOKEN`) — OpenAI-compatible router; example model: `huggingface/deepseek-ai/DeepSeek-R1`; CLI: `openclaw onboard --auth-choice huggingface-api-key`. See [Hugging Face (Inference)](/providers/huggingface).
|
||||
|
||||
## Providers via `models.providers` (custom/base URL)
|
||||
|
||||
@@ -260,32 +259,6 @@ ollama pull llama3.3
|
||||
|
||||
Ollama is automatically detected when running locally at `http://127.0.0.1:11434/v1`. See [/providers/ollama](/providers/ollama) for model recommendations and custom configuration.
|
||||
|
||||
### vLLM
|
||||
|
||||
vLLM is a local (or self-hosted) OpenAI-compatible server:
|
||||
|
||||
- Provider: `vllm`
|
||||
- Auth: Optional (depends on your server)
|
||||
- Default base URL: `http://127.0.0.1:8000/v1`
|
||||
|
||||
To opt in to auto-discovery locally (any value works if your server doesn’t enforce auth):
|
||||
|
||||
```bash
|
||||
export VLLM_API_KEY="vllm-local"
|
||||
```
|
||||
|
||||
Then set a model (replace with one of the IDs returned by `/v1/models`):
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: { model: { primary: "vllm/your-model-id" } },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
See [/providers/vllm](/providers/vllm) for details.
|
||||
|
||||
### Local proxies (LM Studio, vLLM, LiteLLM, etc.)
|
||||
|
||||
Example (OpenAI‑compatible):
|
||||
|
||||
@@ -125,15 +125,11 @@ Notes:
|
||||
Bindings are **deterministic** and **most-specific wins**:
|
||||
|
||||
1. `peer` match (exact DM/group/channel id)
|
||||
2. `parentPeer` match (thread inheritance)
|
||||
3. `guildId + roles` (Discord role routing)
|
||||
4. `guildId` (Discord)
|
||||
5. `teamId` (Slack)
|
||||
6. `accountId` match for a channel
|
||||
7. channel-level match (`accountId: "*"`)
|
||||
8. fallback to default agent (`agents.list[].default`, else first list entry, default: `main`)
|
||||
|
||||
If a binding sets multiple match fields (for example `peer` + `guildId`), all specified fields are required (`AND` semantics).
|
||||
2. `guildId` (Discord)
|
||||
3. `teamId` (Slack)
|
||||
4. `accountId` match for a channel
|
||||
5. channel-level match (`accountId: "*"`)
|
||||
6. fallback to default agent (`agents.list[].default`, else first list entry, default: `main`)
|
||||
|
||||
## Multiple accounts / phone numbers
|
||||
|
||||
|
||||
@@ -786,10 +786,6 @@
|
||||
{
|
||||
"source": "/platforms/northflank",
|
||||
"destination": "/install/northflank"
|
||||
},
|
||||
{
|
||||
"source": "/gateway/trusted-proxy",
|
||||
"destination": "/gateway/trusted-proxy-auth"
|
||||
}
|
||||
],
|
||||
"navigation": {
|
||||
@@ -1110,7 +1106,6 @@
|
||||
"gateway/configuration-reference",
|
||||
"gateway/configuration-examples",
|
||||
"gateway/authentication",
|
||||
"gateway/trusted-proxy-auth",
|
||||
"gateway/health",
|
||||
"gateway/heartbeat",
|
||||
"gateway/doctor",
|
||||
|
||||
@@ -1889,17 +1889,10 @@ See [Plugins](/tools/plugin).
|
||||
port: 18789,
|
||||
bind: "loopback",
|
||||
auth: {
|
||||
mode: "token", // token | password | trusted-proxy
|
||||
mode: "token", // token | password
|
||||
token: "your-token",
|
||||
// password: "your-password", // or OPENCLAW_GATEWAY_PASSWORD
|
||||
// trustedProxy: { userHeader: "x-forwarded-user" }, // for mode=trusted-proxy; see /gateway/trusted-proxy-auth
|
||||
allowTailscale: true,
|
||||
rateLimit: {
|
||||
maxAttempts: 10,
|
||||
windowMs: 60000,
|
||||
lockoutMs: 300000,
|
||||
exemptLoopback: true,
|
||||
},
|
||||
},
|
||||
tailscale: {
|
||||
mode: "off", // off | serve | funnel
|
||||
@@ -1919,12 +1912,6 @@ See [Plugins](/tools/plugin).
|
||||
// password: "your-password",
|
||||
},
|
||||
trustedProxies: ["10.0.0.1"],
|
||||
tools: {
|
||||
// Additional /tools/invoke HTTP denies
|
||||
deny: ["browser"],
|
||||
// Remove tools from the default HTTP deny list
|
||||
allow: ["gateway"],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
@@ -1935,16 +1922,11 @@ See [Plugins](/tools/plugin).
|
||||
- `port`: single multiplexed port for WS + HTTP. Precedence: `--port` > `OPENCLAW_GATEWAY_PORT` > `gateway.port` > `18789`.
|
||||
- `bind`: `auto`, `loopback` (default), `lan` (`0.0.0.0`), `tailnet` (Tailscale IP only), or `custom`.
|
||||
- **Auth**: required by default. Non-loopback binds require a shared token/password. Onboarding wizard generates a token by default.
|
||||
- `auth.mode: "trusted-proxy"`: delegate auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)).
|
||||
- `auth.allowTailscale`: when `true`, Tailscale Serve identity headers satisfy auth (verified via `tailscale whois`). Defaults to `true` when `tailscale.mode = "serve"`.
|
||||
- `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`.
|
||||
- `auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments).
|
||||
- `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth).
|
||||
- `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`.
|
||||
- `gateway.remote.token` is for remote CLI calls only; does not enable local gateway auth.
|
||||
- `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.
|
||||
- `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list).
|
||||
- `gateway.tools.allow`: remove tool names from the default HTTP deny list.
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ See the [full reference](/gateway/configuration-reference) for every available f
|
||||
## Strict validation
|
||||
|
||||
<Warning>
|
||||
OpenClaw only accepts configurations that fully match the schema. Unknown keys, malformed types, or invalid values cause the Gateway to **refuse to start**. The only root-level exception is `$schema` (string), so editors can attach JSON Schema metadata.
|
||||
OpenClaw only accepts configurations that fully match the schema. Unknown keys, malformed types, or invalid values cause the Gateway to **refuse to start**.
|
||||
</Warning>
|
||||
|
||||
When validation fails:
|
||||
|
||||
@@ -26,7 +26,6 @@ Notes:
|
||||
|
||||
- When `gateway.auth.mode="token"`, use `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`).
|
||||
- When `gateway.auth.mode="password"`, use `gateway.auth.password` (or `OPENCLAW_GATEWAY_PASSWORD`).
|
||||
- If `gateway.auth.rateLimit` is configured and too many auth failures occur, the endpoint returns `429` with `Retry-After`.
|
||||
|
||||
## Choosing an agent
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ Notes:
|
||||
|
||||
- When `gateway.auth.mode="token"`, use `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`).
|
||||
- When `gateway.auth.mode="password"`, use `gateway.auth.password` (or `OPENCLAW_GATEWAY_PASSWORD`).
|
||||
- If `gateway.auth.rateLimit` is configured and too many auth failures occur, the endpoint returns `429` with `Retry-After`.
|
||||
|
||||
## Choosing an agent
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ Start with the smallest access that still works, then widen it as you gain confi
|
||||
- **Browser control exposure** (remote nodes, relay ports, remote CDP endpoints).
|
||||
- **Local disk hygiene** (permissions, symlinks, config includes, “synced folder” paths).
|
||||
- **Plugins** (extensions exist without an explicit allowlist).
|
||||
- **Policy drift/misconfig** (sandbox docker settings configured but sandbox mode off; ineffective `gateway.nodes.denyCommands` patterns; global `tools.profile="minimal"` overridden by per-agent profiles; extension plugin tools reachable under permissive tool policy).
|
||||
- **Model hygiene** (warn when configured models look legacy; not a hard block).
|
||||
|
||||
If you run `--deep`, OpenClaw also attempts a best-effort live Gateway probe.
|
||||
@@ -439,7 +438,6 @@ Auth modes:
|
||||
|
||||
- `gateway.auth.mode: "token"`: shared bearer token (recommended for most setups).
|
||||
- `gateway.auth.mode: "password"`: password auth (prefer setting via env: `OPENCLAW_GATEWAY_PASSWORD`).
|
||||
- `gateway.auth.mode: "trusted-proxy"`: trust an identity-aware reverse proxy to authenticate users and pass identity via headers (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)).
|
||||
|
||||
Rotation checklist (token/password):
|
||||
|
||||
@@ -460,7 +458,7 @@ injected by Tailscale.
|
||||
|
||||
**Security rule:** do not forward these headers from your own reverse proxy. If
|
||||
you terminate TLS or proxy in front of the gateway, disable
|
||||
`gateway.auth.allowTailscale` and use token/password auth (or [Trusted Proxy Auth](/gateway/trusted-proxy-auth)) instead.
|
||||
`gateway.auth.allowTailscale` and use token/password auth instead.
|
||||
|
||||
Trusted proxies:
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ Notes:
|
||||
|
||||
- When `gateway.auth.mode="token"`, use `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`).
|
||||
- When `gateway.auth.mode="password"`, use `gateway.auth.password` (or `OPENCLAW_GATEWAY_PASSWORD`).
|
||||
- If `gateway.auth.rateLimit` is configured and too many auth failures occur, the endpoint returns `429` with `Retry-After`.
|
||||
|
||||
## Request body
|
||||
|
||||
@@ -59,28 +58,6 @@ Tool availability is filtered through the same policy chain used by Gateway agen
|
||||
|
||||
If a tool is not allowed by policy, the endpoint returns **404**.
|
||||
|
||||
Gateway HTTP also applies a hard deny list by default (even if session policy allows the tool):
|
||||
|
||||
- `sessions_spawn`
|
||||
- `sessions_send`
|
||||
- `gateway`
|
||||
- `whatsapp_login`
|
||||
|
||||
You can customize this deny list via `gateway.tools`:
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
tools: {
|
||||
// Additional tools to block over HTTP /tools/invoke
|
||||
deny: ["browser"],
|
||||
// Remove tools from the default deny list
|
||||
allow: ["gateway"],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
To help group policies resolve context, you can optionally set:
|
||||
|
||||
- `x-openclaw-message-channel: <channel>` (example: `slack`, `telegram`)
|
||||
@@ -89,12 +66,10 @@ To help group policies resolve context, you can optionally set:
|
||||
## Responses
|
||||
|
||||
- `200` → `{ ok: true, result }`
|
||||
- `400` → `{ ok: false, error: { type, message } }` (invalid request or tool input error)
|
||||
- `400` → `{ ok: false, error: { type, message } }` (invalid request or tool error)
|
||||
- `401` → unauthorized
|
||||
- `429` → auth rate-limited (`Retry-After` set)
|
||||
- `404` → tool not available (not found or not allowlisted)
|
||||
- `405` → method not allowed
|
||||
- `500` → `{ ok: false, error: { type, message } }` (unexpected tool execution error; sanitized message)
|
||||
|
||||
## Example
|
||||
|
||||
|
||||
@@ -1,267 +0,0 @@
|
||||
---
|
||||
summary: "Delegate gateway authentication to a trusted reverse proxy (Pomerium, Caddy, nginx + OAuth)"
|
||||
read_when:
|
||||
- Running OpenClaw behind an identity-aware proxy
|
||||
- Setting up Pomerium, Caddy, or nginx with OAuth in front of OpenClaw
|
||||
- Fixing WebSocket 1008 unauthorized errors with reverse proxy setups
|
||||
---
|
||||
|
||||
# Trusted Proxy Auth
|
||||
|
||||
> ⚠️ **Security-sensitive feature.** This mode delegates authentication entirely to your reverse proxy. Misconfiguration can expose your Gateway to unauthorized access. Read this page carefully before enabling.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use `trusted-proxy` auth mode when:
|
||||
|
||||
- You run OpenClaw behind an **identity-aware proxy** (Pomerium, Caddy + OAuth, nginx + oauth2-proxy, Traefik + forward auth)
|
||||
- Your proxy handles all authentication and passes user identity via headers
|
||||
- You're in a Kubernetes or container environment where the proxy is the only path to the Gateway
|
||||
- You're hitting WebSocket `1008 unauthorized` errors because browsers can't pass tokens in WS payloads
|
||||
|
||||
## When NOT to Use
|
||||
|
||||
- If your proxy doesn't authenticate users (just a TLS terminator or load balancer)
|
||||
- If there's any path to the Gateway that bypasses the proxy (firewall holes, internal network access)
|
||||
- If you're unsure whether your proxy correctly strips/overwrites forwarded headers
|
||||
- If you only need personal single-user access (consider Tailscale Serve + loopback for simpler setup)
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Your reverse proxy authenticates users (OAuth, OIDC, SAML, etc.)
|
||||
2. Proxy adds a header with the authenticated user identity (e.g., `x-forwarded-user: nick@example.com`)
|
||||
3. OpenClaw checks that the request came from a **trusted proxy IP** (configured in `gateway.trustedProxies`)
|
||||
4. OpenClaw extracts the user identity from the configured header
|
||||
5. If everything checks out, the request is authorized
|
||||
|
||||
## Configuration
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
// Must bind to network interface (not loopback)
|
||||
bind: "lan",
|
||||
|
||||
// CRITICAL: Only add your proxy's IP(s) here
|
||||
trustedProxies: ["10.0.0.1", "172.17.0.1"],
|
||||
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy: {
|
||||
// Header containing authenticated user identity (required)
|
||||
userHeader: "x-forwarded-user",
|
||||
|
||||
// Optional: headers that MUST be present (proxy verification)
|
||||
requiredHeaders: ["x-forwarded-proto", "x-forwarded-host"],
|
||||
|
||||
// Optional: restrict to specific users (empty = allow all)
|
||||
allowUsers: ["nick@example.com", "admin@company.org"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Reference
|
||||
|
||||
| Field | Required | Description |
|
||||
| ------------------------------------------- | -------- | --------------------------------------------------------------------------- |
|
||||
| `gateway.trustedProxies` | Yes | Array of proxy IP addresses to trust. Requests from other IPs are rejected. |
|
||||
| `gateway.auth.mode` | Yes | Must be `"trusted-proxy"` |
|
||||
| `gateway.auth.trustedProxy.userHeader` | Yes | Header name containing the authenticated user identity |
|
||||
| `gateway.auth.trustedProxy.requiredHeaders` | No | Additional headers that must be present for the request to be trusted |
|
||||
| `gateway.auth.trustedProxy.allowUsers` | No | Allowlist of user identities. Empty means allow all authenticated users. |
|
||||
|
||||
## Proxy Setup Examples
|
||||
|
||||
### Pomerium
|
||||
|
||||
Pomerium passes identity in `x-pomerium-claim-email` (or other claim headers) and a JWT in `x-pomerium-jwt-assertion`.
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
trustedProxies: ["10.0.0.1"], // Pomerium's IP
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy: {
|
||||
userHeader: "x-pomerium-claim-email",
|
||||
requiredHeaders: ["x-pomerium-jwt-assertion"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Pomerium config snippet:
|
||||
|
||||
```yaml
|
||||
routes:
|
||||
- from: https://openclaw.example.com
|
||||
to: http://openclaw-gateway:18789
|
||||
policy:
|
||||
- allow:
|
||||
or:
|
||||
- email:
|
||||
is: nick@example.com
|
||||
pass_identity_headers: true
|
||||
```
|
||||
|
||||
### Caddy with OAuth
|
||||
|
||||
Caddy with the `caddy-security` plugin can authenticate users and pass identity headers.
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
trustedProxies: ["127.0.0.1"], // Caddy's IP (if on same host)
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Caddyfile snippet:
|
||||
|
||||
```
|
||||
openclaw.example.com {
|
||||
authenticate with oauth2_provider
|
||||
authorize with policy1
|
||||
|
||||
reverse_proxy openclaw:18789 {
|
||||
header_up X-Forwarded-User {http.auth.user.email}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### nginx + oauth2-proxy
|
||||
|
||||
oauth2-proxy authenticates users and passes identity in `x-auth-request-email`.
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
trustedProxies: ["10.0.0.1"], // nginx/oauth2-proxy IP
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy: {
|
||||
userHeader: "x-auth-request-email",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
nginx config snippet:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
auth_request /oauth2/auth;
|
||||
auth_request_set $user $upstream_http_x_auth_request_email;
|
||||
|
||||
proxy_pass http://openclaw:18789;
|
||||
proxy_set_header X-Auth-Request-Email $user;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
```
|
||||
|
||||
### Traefik with Forward Auth
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
bind: "lan",
|
||||
trustedProxies: ["172.17.0.1"], // Traefik container IP
|
||||
auth: {
|
||||
mode: "trusted-proxy",
|
||||
trustedProxy: {
|
||||
userHeader: "x-forwarded-user",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
Before enabling trusted-proxy auth, verify:
|
||||
|
||||
- [ ] **Proxy is the only path**: The Gateway port is firewalled from everything except your proxy
|
||||
- [ ] **trustedProxies is minimal**: Only your actual proxy IPs, not entire subnets
|
||||
- [ ] **Proxy strips headers**: Your proxy overwrites (not appends) `x-forwarded-*` headers from clients
|
||||
- [ ] **TLS termination**: Your proxy handles TLS; users connect via HTTPS
|
||||
- [ ] **allowUsers is set** (recommended): Restrict to known users rather than allowing anyone authenticated
|
||||
|
||||
## Security Audit
|
||||
|
||||
`openclaw security audit` will flag trusted-proxy auth with a **critical** severity finding. This is intentional — it's a reminder that you're delegating security to your proxy setup.
|
||||
|
||||
The audit checks for:
|
||||
|
||||
- Missing `trustedProxies` configuration
|
||||
- Missing `userHeader` configuration
|
||||
- Empty `allowUsers` (allows any authenticated user)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "trusted_proxy_untrusted_source"
|
||||
|
||||
The request didn't come from an IP in `gateway.trustedProxies`. Check:
|
||||
|
||||
- Is the proxy IP correct? (Docker container IPs can change)
|
||||
- Is there a load balancer in front of your proxy?
|
||||
- Use `docker inspect` or `kubectl get pods -o wide` to find actual IPs
|
||||
|
||||
### "trusted_proxy_user_missing"
|
||||
|
||||
The user header was empty or missing. Check:
|
||||
|
||||
- Is your proxy configured to pass identity headers?
|
||||
- Is the header name correct? (case-insensitive, but spelling matters)
|
||||
- Is the user actually authenticated at the proxy?
|
||||
|
||||
### "trusted*proxy_missing_header*\*"
|
||||
|
||||
A required header wasn't present. Check:
|
||||
|
||||
- Your proxy configuration for those specific headers
|
||||
- Whether headers are being stripped somewhere in the chain
|
||||
|
||||
### "trusted_proxy_user_not_allowed"
|
||||
|
||||
The user is authenticated but not in `allowUsers`. Either add them or remove the allowlist.
|
||||
|
||||
### WebSocket Still Failing
|
||||
|
||||
Make sure your proxy:
|
||||
|
||||
- Supports WebSocket upgrades (`Upgrade: websocket`, `Connection: upgrade`)
|
||||
- Passes the identity headers on WebSocket upgrade requests (not just HTTP)
|
||||
- Doesn't have a separate auth path for WebSocket connections
|
||||
|
||||
## Migration from Token Auth
|
||||
|
||||
If you're moving from token auth to trusted-proxy:
|
||||
|
||||
1. Configure your proxy to authenticate users and pass headers
|
||||
2. Test the proxy setup independently (curl with headers)
|
||||
3. Update OpenClaw config with trusted-proxy auth
|
||||
4. Restart the Gateway
|
||||
5. Test WebSocket connections from the Control UI
|
||||
6. Run `openclaw security audit` and review findings
|
||||
|
||||
## Related
|
||||
|
||||
- [Security](/gateway/security) — full security guide
|
||||
- [Configuration](/gateway/configuration) — config reference
|
||||
- [Remote Access](/gateway/remote) — other remote access patterns
|
||||
- [Tailscale](/gateway/tailscale) — simpler alternative for tailnet-only access
|
||||
@@ -52,23 +52,12 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
|
||||
- Runs in CI
|
||||
- No real keys required
|
||||
- Should be fast and stable
|
||||
- Pool note:
|
||||
- OpenClaw uses Vitest `vmForks` on Node 22/23 for faster unit shards.
|
||||
- On Node 24+, OpenClaw automatically falls back to regular `forks` to avoid Node VM linking errors (`ERR_VM_MODULE_LINK_FAILURE` / `module is already linked`).
|
||||
- Override manually with `OPENCLAW_TEST_VM_FORKS=0` (force `forks`) or `OPENCLAW_TEST_VM_FORKS=1` (force `vmForks`).
|
||||
|
||||
### E2E (gateway smoke)
|
||||
|
||||
- Command: `pnpm test:e2e`
|
||||
- Config: `vitest.e2e.config.ts`
|
||||
- Files: `src/**/*.e2e.test.ts`
|
||||
- Runtime defaults:
|
||||
- Uses Vitest `vmForks` for faster file startup.
|
||||
- Uses adaptive workers (CI: 2-4, local: 4-8).
|
||||
- Runs in silent mode by default to reduce console I/O overhead.
|
||||
- Useful overrides:
|
||||
- `OPENCLAW_E2E_WORKERS=<n>` to force worker count (capped at 16).
|
||||
- `OPENCLAW_E2E_VERBOSE=1` to re-enable verbose console output.
|
||||
- Scope:
|
||||
- Multi-instance gateway end-to-end behavior
|
||||
- WebSocket/HTTP surfaces, node pairing, and heavier networking
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
---
|
||||
summary: "Hugging Face Inference setup (auth + model selection)"
|
||||
read_when:
|
||||
- You want to use Hugging Face Inference with OpenClaw
|
||||
- You need the HF token env var or CLI auth choice
|
||||
title: "Hugging Face (Inference)"
|
||||
---
|
||||
|
||||
# Hugging Face (Inference)
|
||||
|
||||
[Hugging Face Inference Providers](https://huggingface.co/docs/inference-providers) offer OpenAI-compatible chat completions through a single router API. You get access to many models (DeepSeek, Llama, and more) with one token. OpenClaw uses the **OpenAI-compatible endpoint** (chat completions only); for text-to-image, embeddings, or speech use the [HF inference clients](https://huggingface.co/docs/api-inference/quicktour) directly.
|
||||
|
||||
- Provider: `huggingface`
|
||||
- Auth: `HUGGINGFACE_HUB_TOKEN` or `HF_TOKEN` (fine-grained token with **Make calls to Inference Providers**)
|
||||
- API: OpenAI-compatible (`https://router.huggingface.co/v1`)
|
||||
- Billing: Single HF token; [pricing](https://huggingface.co/docs/inference-providers/pricing) follows provider rates with a free tier.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Create a fine-grained token at [Hugging Face → Settings → Tokens](https://huggingface.co/settings/tokens/new?ownUserPermissions=inference.serverless.write&tokenType=fineGrained) with the **Make calls to Inference Providers** permission.
|
||||
2. Run onboarding and choose **Hugging Face** in the provider dropdown, then enter your API key when prompted:
|
||||
|
||||
```bash
|
||||
openclaw onboard --auth-choice huggingface-api-key
|
||||
```
|
||||
|
||||
3. In the **Default Hugging Face model** dropdown, pick the model you want (the list is loaded from the Inference API when you have a valid token; otherwise a built-in list is shown). Your choice is saved as the default model.
|
||||
4. You can also set or change the default model later in config:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "huggingface/deepseek-ai/DeepSeek-R1" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Non-interactive example
|
||||
|
||||
```bash
|
||||
openclaw onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice huggingface-api-key \
|
||||
--huggingface-api-key "$HF_TOKEN"
|
||||
```
|
||||
|
||||
This will set `huggingface/deepseek-ai/DeepSeek-R1` as the default model.
|
||||
|
||||
## Environment note
|
||||
|
||||
If the Gateway runs as a daemon (launchd/systemd), make sure `HUGGINGFACE_HUB_TOKEN` or `HF_TOKEN`
|
||||
is available to that process (for example, in `~/.openclaw/.env` or via
|
||||
`env.shellEnv`).
|
||||
|
||||
## Model discovery and onboarding dropdown
|
||||
|
||||
OpenClaw discovers models by calling the **Inference endpoint directly**:
|
||||
|
||||
```bash
|
||||
GET https://router.huggingface.co/v1/models
|
||||
```
|
||||
|
||||
(Optional: send `Authorization: Bearer $HUGGINGFACE_HUB_TOKEN` or `$HF_TOKEN` for the full list; some endpoints return a subset without auth.) The response is OpenAI-style `{ "object": "list", "data": [ { "id": "Qwen/Qwen3-8B", "owned_by": "Qwen", ... }, ... ] }`.
|
||||
|
||||
When you configure a Hugging Face API key (via onboarding, `HUGGINGFACE_HUB_TOKEN`, or `HF_TOKEN`), OpenClaw uses this GET to discover available chat-completion models. During **interactive onboarding**, after you enter your token you see a **Default Hugging Face model** dropdown populated from that list (or the built-in catalog if the request fails). At runtime (e.g. Gateway startup), when a key is present, OpenClaw again calls **GET** `https://router.huggingface.co/v1/models` to refresh the catalog. The list is merged with a built-in catalog (for metadata like context window and cost). If the request fails or no key is set, only the built-in catalog is used.
|
||||
|
||||
## Model names and editable options
|
||||
|
||||
- **Name from API:** The model display name is **hydrated from GET /v1/models** when the API returns `name`, `title`, or `display_name`; otherwise it is derived from the model id (e.g. `deepseek-ai/DeepSeek-R1` → “DeepSeek R1”).
|
||||
- **Override display name:** You can set a custom label per model in config so it appears the way you want in the CLI and UI:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"huggingface/deepseek-ai/DeepSeek-R1": { alias: "DeepSeek R1 (fast)" },
|
||||
"huggingface/deepseek-ai/DeepSeek-R1:cheapest": { alias: "DeepSeek R1 (cheap)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- **Provider / policy selection:** Append a suffix to the **model id** to choose how the router picks the backend:
|
||||
- **`:fastest`** — highest throughput (router picks; provider choice is **locked** — no interactive backend picker).
|
||||
- **`:cheapest`** — lowest cost per output token (router picks; provider choice is **locked**).
|
||||
- **`:provider`** — force a specific backend (e.g. `:sambanova`, `:together`).
|
||||
|
||||
When you select **:cheapest** or **:fastest** (e.g. in the onboarding model dropdown), the provider is locked: the router decides by cost or speed and no optional “prefer specific backend” step is shown. You can add these as separate entries in `models.providers.huggingface.models` or set `model.primary` with the suffix. You can also set your default order in [Inference Provider settings](https://hf.co/settings/inference-providers) (no suffix = use that order).
|
||||
|
||||
- **Config merge:** Existing entries in `models.providers.huggingface.models` (e.g. in `models.json`) are kept when config is merged. So any custom `name`, `alias`, or model options you set there are preserved.
|
||||
|
||||
## Model IDs and configuration examples
|
||||
|
||||
Model refs use the form `huggingface/<org>/<model>` (Hub-style IDs). The list below is from **GET** `https://router.huggingface.co/v1/models`; your catalog may include more.
|
||||
|
||||
**Example IDs (from the inference endpoint):**
|
||||
|
||||
| Model | Ref (prefix with `huggingface/`) |
|
||||
| ---------------------- | ----------------------------------- |
|
||||
| DeepSeek R1 | `deepseek-ai/DeepSeek-R1` |
|
||||
| DeepSeek V3.2 | `deepseek-ai/DeepSeek-V3.2` |
|
||||
| Qwen3 8B | `Qwen/Qwen3-8B` |
|
||||
| Qwen2.5 7B Instruct | `Qwen/Qwen2.5-7B-Instruct` |
|
||||
| Qwen3 32B | `Qwen/Qwen3-32B` |
|
||||
| Llama 3.3 70B Instruct | `meta-llama/Llama-3.3-70B-Instruct` |
|
||||
| Llama 3.1 8B Instruct | `meta-llama/Llama-3.1-8B-Instruct` |
|
||||
| GPT-OSS 120B | `openai/gpt-oss-120b` |
|
||||
| GLM 4.7 | `zai-org/GLM-4.7` |
|
||||
| Kimi K2.5 | `moonshotai/Kimi-K2.5` |
|
||||
|
||||
You can append `:fastest`, `:cheapest`, or `:provider` (e.g. `:together`, `:sambanova`) to the model id. Set your default order in [Inference Provider settings](https://hf.co/settings/inference-providers); see [Inference Providers](https://huggingface.co/docs/inference-providers) and **GET** `https://router.huggingface.co/v1/models` for the full list.
|
||||
|
||||
### Complete configuration examples
|
||||
|
||||
**Primary DeepSeek R1 with Qwen fallback:**
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "huggingface/deepseek-ai/DeepSeek-R1",
|
||||
fallbacks: ["huggingface/Qwen/Qwen3-8B"],
|
||||
},
|
||||
models: {
|
||||
"huggingface/deepseek-ai/DeepSeek-R1": { alias: "DeepSeek R1" },
|
||||
"huggingface/Qwen/Qwen3-8B": { alias: "Qwen3 8B" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Qwen as default, with :cheapest and :fastest variants:**
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "huggingface/Qwen/Qwen3-8B" },
|
||||
models: {
|
||||
"huggingface/Qwen/Qwen3-8B": { alias: "Qwen3 8B" },
|
||||
"huggingface/Qwen/Qwen3-8B:cheapest": { alias: "Qwen3 8B (cheapest)" },
|
||||
"huggingface/Qwen/Qwen3-8B:fastest": { alias: "Qwen3 8B (fastest)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**DeepSeek + Llama + GPT-OSS with aliases:**
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "huggingface/deepseek-ai/DeepSeek-V3.2",
|
||||
fallbacks: [
|
||||
"huggingface/meta-llama/Llama-3.3-70B-Instruct",
|
||||
"huggingface/openai/gpt-oss-120b",
|
||||
],
|
||||
},
|
||||
models: {
|
||||
"huggingface/deepseek-ai/DeepSeek-V3.2": { alias: "DeepSeek V3.2" },
|
||||
"huggingface/meta-llama/Llama-3.3-70B-Instruct": { alias: "Llama 3.3 70B" },
|
||||
"huggingface/openai/gpt-oss-120b": { alias: "GPT-OSS 120B" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Force a specific backend with :provider:**
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "huggingface/deepseek-ai/DeepSeek-R1:together" },
|
||||
models: {
|
||||
"huggingface/deepseek-ai/DeepSeek-R1:together": { alias: "DeepSeek R1 (Together)" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Multiple Qwen and DeepSeek models with policy suffixes:**
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "huggingface/Qwen/Qwen2.5-7B-Instruct:cheapest" },
|
||||
models: {
|
||||
"huggingface/Qwen/Qwen2.5-7B-Instruct": { alias: "Qwen2.5 7B" },
|
||||
"huggingface/Qwen/Qwen2.5-7B-Instruct:cheapest": { alias: "Qwen2.5 7B (cheap)" },
|
||||
"huggingface/deepseek-ai/DeepSeek-R1:fastest": { alias: "DeepSeek R1 (fast)" },
|
||||
"huggingface/meta-llama/Llama-3.1-8B-Instruct": { alias: "Llama 3.1 8B" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
@@ -51,11 +51,8 @@ See [Venice AI](/providers/venice).
|
||||
- [GLM models](/providers/glm)
|
||||
- [MiniMax](/providers/minimax)
|
||||
- [Venice (Venice AI, privacy-focused)](/providers/venice)
|
||||
- [Hugging Face (Inference)](/providers/huggingface)
|
||||
- [Ollama (local models)](/providers/ollama)
|
||||
- [vLLM (local models)](/providers/vllm)
|
||||
- [Qianfan](/providers/qianfan)
|
||||
- [NVIDIA](/providers/nvidia)
|
||||
|
||||
## Transcription providers
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
summary: "Use NVIDIA's OpenAI-compatible API in OpenClaw"
|
||||
read_when:
|
||||
- You want to use NVIDIA models in OpenClaw
|
||||
- You need NVIDIA_API_KEY setup
|
||||
title: "NVIDIA"
|
||||
---
|
||||
|
||||
# NVIDIA
|
||||
|
||||
NVIDIA provides an OpenAI-compatible API at `https://integrate.api.nvidia.com/v1` for Nemotron and NeMo models. Authenticate with an API key from [NVIDIA NGC](https://catalog.ngc.nvidia.com/).
|
||||
|
||||
## CLI setup
|
||||
|
||||
Export the key once, then run onboarding and set an NVIDIA model:
|
||||
|
||||
```bash
|
||||
export NVIDIA_API_KEY="nvapi-..."
|
||||
openclaw onboard --auth-choice skip
|
||||
openclaw models set nvidia/nvidia/llama-3.1-nemotron-70b-instruct
|
||||
```
|
||||
|
||||
If you still pass `--token`, remember it lands in shell history and `ps` output; prefer the env var when possible.
|
||||
|
||||
## Config snippet
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { NVIDIA_API_KEY: "nvapi-..." },
|
||||
models: {
|
||||
providers: {
|
||||
nvidia: {
|
||||
baseUrl: "https://integrate.api.nvidia.com/v1",
|
||||
api: "openai-completions",
|
||||
},
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "nvidia/nvidia/llama-3.1-nemotron-70b-instruct" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Model IDs
|
||||
|
||||
- `nvidia/llama-3.1-nemotron-70b-instruct` (default)
|
||||
- `meta/llama-3.3-70b-instruct`
|
||||
- `nvidia/mistral-nemo-minitron-8b-8k-instruct`
|
||||
|
||||
## Notes
|
||||
|
||||
- OpenAI-compatible `/v1` endpoint; use an API key from NVIDIA NGC.
|
||||
- Provider auto-enables when `NVIDIA_API_KEY` is set; uses static defaults (131,072-token context window, 4,096 max tokens).
|
||||
@@ -8,7 +8,7 @@ title: "Ollama"
|
||||
|
||||
# Ollama
|
||||
|
||||
Ollama is a local LLM runtime that makes it easy to run open-source models on your machine. OpenClaw integrates with Ollama's native API (`/api/chat`), supporting streaming and tool calling, and can **auto-discover tool-capable models** when you opt in with `OLLAMA_API_KEY` (or an auth profile) and do not define an explicit `models.providers.ollama` entry.
|
||||
Ollama is a local LLM runtime that makes it easy to run open-source models on your machine. OpenClaw integrates with Ollama's OpenAI-compatible API and can **auto-discover tool-capable models** when you opt in with `OLLAMA_API_KEY` (or an auth profile) and do not define an explicit `models.providers.ollama` entry.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -101,9 +101,10 @@ Use explicit config when:
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://ollama-host:11434",
|
||||
// Use a host that includes /v1 for OpenAI-compatible APIs
|
||||
baseUrl: "http://ollama-host:11434/v1",
|
||||
apiKey: "ollama-local",
|
||||
api: "ollama",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-oss:20b",
|
||||
@@ -133,7 +134,7 @@ If Ollama is running on a different host or port (explicit config disables auto-
|
||||
providers: {
|
||||
ollama: {
|
||||
apiKey: "ollama-local",
|
||||
baseUrl: "http://ollama-host:11434",
|
||||
baseUrl: "http://ollama-host:11434/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -173,28 +174,45 @@ Ollama is free and runs locally, so all model costs are set to $0.
|
||||
|
||||
### Streaming Configuration
|
||||
|
||||
OpenClaw's Ollama integration uses the **native Ollama API** (`/api/chat`) by default, which fully supports streaming and tool calling simultaneously. No special configuration is needed.
|
||||
Due to a [known issue](https://github.com/badlogic/pi-mono/issues/1205) in the underlying SDK with Ollama's response format, **streaming is disabled by default** for Ollama models. This prevents corrupted responses when using tool-capable models.
|
||||
|
||||
#### Legacy OpenAI-Compatible Mode
|
||||
When streaming is disabled, responses are delivered all at once (non-streaming mode), which avoids the issue where interleaved content/reasoning deltas cause garbled output.
|
||||
|
||||
If you need to use the OpenAI-compatible endpoint instead (e.g., behind a proxy that only supports OpenAI format), set `api: "openai-completions"` explicitly:
|
||||
#### Re-enable Streaming (Advanced)
|
||||
|
||||
If you want to re-enable streaming for Ollama (may cause issues with tool-capable models):
|
||||
|
||||
```json5
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://ollama-host:11434/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "ollama-local",
|
||||
models: [...]
|
||||
}
|
||||
}
|
||||
}
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"ollama/gpt-oss:20b": {
|
||||
streaming: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Note: The OpenAI-compatible endpoint may not support streaming + tool calling simultaneously. You may need to disable streaming with `params: { streaming: false }` in model config.
|
||||
#### Disable Streaming for Other Providers
|
||||
|
||||
You can also disable streaming for any provider if needed:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"openai/gpt-4": {
|
||||
streaming: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Context windows
|
||||
|
||||
@@ -243,6 +261,15 @@ ps aux | grep ollama
|
||||
ollama serve
|
||||
```
|
||||
|
||||
### Corrupted responses or tool names in output
|
||||
|
||||
If you see garbled responses containing tool names (like `sessions_send`, `memory_get`) or fragmented text when using Ollama models, this is due to an upstream SDK issue with streaming responses. **This is fixed by default** in the latest OpenClaw version by disabling streaming for Ollama models.
|
||||
|
||||
If you manually enabled streaming and experience this issue:
|
||||
|
||||
1. Remove the `streaming: true` configuration from your Ollama model entries, or
|
||||
2. Explicitly set `streaming: false` for Ollama models (see [Streaming Configuration](#streaming-configuration))
|
||||
|
||||
## See Also
|
||||
|
||||
- [Model Providers](/concepts/model-providers) - Overview of all providers
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
---
|
||||
summary: "Run OpenClaw with vLLM (OpenAI-compatible local server)"
|
||||
read_when:
|
||||
- You want to run OpenClaw against a local vLLM server
|
||||
- You want OpenAI-compatible /v1 endpoints with your own models
|
||||
title: "vLLM"
|
||||
---
|
||||
|
||||
# vLLM
|
||||
|
||||
vLLM can serve open-source (and some custom) models via an **OpenAI-compatible** HTTP API. OpenClaw can connect to vLLM using the `openai-completions` API.
|
||||
|
||||
OpenClaw can also **auto-discover** available models from vLLM when you opt in with `VLLM_API_KEY` (any value works if your server doesn’t enforce auth) and you do not define an explicit `models.providers.vllm` entry.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. Start vLLM with an OpenAI-compatible server.
|
||||
|
||||
Your base URL should expose `/v1` endpoints (e.g. `/v1/models`, `/v1/chat/completions`). vLLM commonly runs on:
|
||||
|
||||
- `http://127.0.0.1:8000/v1`
|
||||
|
||||
2. Opt in (any value works if no auth is configured):
|
||||
|
||||
```bash
|
||||
export VLLM_API_KEY="vllm-local"
|
||||
```
|
||||
|
||||
3. Select a model (replace with one of your vLLM model IDs):
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "vllm/your-model-id" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Model discovery (implicit provider)
|
||||
|
||||
When `VLLM_API_KEY` is set (or an auth profile exists) and you **do not** define `models.providers.vllm`, OpenClaw will query:
|
||||
|
||||
- `GET http://127.0.0.1:8000/v1/models`
|
||||
|
||||
…and convert the returned IDs into model entries.
|
||||
|
||||
If you set `models.providers.vllm` explicitly, auto-discovery is skipped and you must define models manually.
|
||||
|
||||
## Explicit configuration (manual models)
|
||||
|
||||
Use explicit config when:
|
||||
|
||||
- vLLM runs on a different host/port.
|
||||
- You want to pin `contextWindow`/`maxTokens` values.
|
||||
- Your server requires a real API key (or you want to control headers).
|
||||
|
||||
```json5
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
vllm: {
|
||||
baseUrl: "http://127.0.0.1:8000/v1",
|
||||
apiKey: "${VLLM_API_KEY}",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "your-model-id",
|
||||
name: "Local vLLM Model",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- Check the server is reachable:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/v1/models
|
||||
```
|
||||
|
||||
- If requests fail with auth errors, set a real `VLLM_API_KEY` that matches your server configuration, or configure the provider explicitly under `models.providers.vllm`.
|
||||
@@ -11,7 +11,7 @@ title: "Strict Config Validation"
|
||||
|
||||
## Goals
|
||||
|
||||
- **Reject unknown config keys everywhere** (root + nested), except root `$schema` metadata.
|
||||
- **Reject unknown config keys everywhere** (root + nested).
|
||||
- **Reject plugin config without a schema**; don’t load that plugin.
|
||||
- **Remove legacy auto-migration on load**; migrations run via doctor only.
|
||||
- **Auto-run doctor (dry-run) on startup**; if invalid, block non-diagnostic commands.
|
||||
@@ -24,7 +24,7 @@ title: "Strict Config Validation"
|
||||
## Strict validation rules
|
||||
|
||||
- Config must match the schema exactly at every level.
|
||||
- Unknown keys are validation errors (no passthrough at root or nested), except root `$schema` when it is a string.
|
||||
- Unknown keys are validation errors (no passthrough at root or nested).
|
||||
- `plugins.entries.<id>.config` must be validated by the plugin’s schema.
|
||||
- If a plugin lacks a schema, **reject plugin load** and surface a clear error.
|
||||
- Unknown `channels.<id>` keys are errors unless a plugin manifest declares the channel id.
|
||||
|
||||
@@ -11,8 +11,7 @@ title: "Tests"
|
||||
|
||||
- `pnpm test:force`: Kills any lingering gateway process holding the default control port, then runs the full Vitest suite with an isolated gateway port so server tests don’t collide with a running instance. Use this when a prior gateway run left port 18789 occupied.
|
||||
- `pnpm test:coverage`: Runs Vitest with V8 coverage. Global thresholds are 70% lines/branches/functions/statements. Coverage excludes integration-heavy entrypoints (CLI wiring, gateway/telegram bridges, webchat static server) to keep the target focused on unit-testable logic.
|
||||
- `pnpm test` on Node 24+: OpenClaw auto-disables Vitest `vmForks` and uses `forks` to avoid `ERR_VM_MODULE_LINK_FAILURE` / `module is already linked`. You can force behavior with `OPENCLAW_TEST_VM_FORKS=0|1`.
|
||||
- `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing). Defaults to `vmForks` + adaptive workers in `vitest.e2e.config.ts`; tune with `OPENCLAW_E2E_WORKERS=<n>` and set `OPENCLAW_E2E_VERBOSE=1` for verbose logs.
|
||||
- `pnpm test:e2e`: Runs gateway end-to-end smoke tests (multi-instance WS/HTTP/node pairing).
|
||||
- `pnpm test:live`: Runs provider live tests (minimax/zai). Requires API keys and `LIVE=1` (or provider-specific `*_LIVE_TEST=1`) to unskip.
|
||||
|
||||
## Model latency bench (local keys)
|
||||
|
||||
@@ -409,9 +409,9 @@ Actions:
|
||||
- `openclaw browser scrollintoview e12`
|
||||
- `openclaw browser drag 10 11`
|
||||
- `openclaw browser select 9 OptionA OptionB`
|
||||
- `openclaw browser download e12 report.pdf`
|
||||
- `openclaw browser waitfordownload report.pdf`
|
||||
- `openclaw browser upload /tmp/openclaw/uploads/file.pdf`
|
||||
- `openclaw browser download e12 /tmp/report.pdf`
|
||||
- `openclaw browser waitfordownload /tmp/report.pdf`
|
||||
- `openclaw browser upload /tmp/file.pdf`
|
||||
- `openclaw browser fill --fields '[{"ref":"1","type":"text","value":"Ada"}]'`
|
||||
- `openclaw browser dialog --accept`
|
||||
- `openclaw browser wait --text "Done"`
|
||||
@@ -444,11 +444,6 @@ Notes:
|
||||
|
||||
- `upload` and `dialog` are **arming** calls; run them before the click/press
|
||||
that triggers the chooser/dialog.
|
||||
- Download and trace output paths are constrained to OpenClaw temp roots:
|
||||
- traces: `/tmp/openclaw` (fallback: `${os.tmpdir()}/openclaw`)
|
||||
- downloads: `/tmp/openclaw/downloads` (fallback: `${os.tmpdir()}/openclaw/downloads`)
|
||||
- Upload paths are constrained to an OpenClaw temp uploads root:
|
||||
- uploads: `/tmp/openclaw/uploads` (fallback: `${os.tmpdir()}/openclaw/uploads`)
|
||||
- `upload` can also set file inputs directly via `--input-ref` or `--element`.
|
||||
- `snapshot`:
|
||||
- `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref="<n>"`).
|
||||
|
||||
@@ -175,9 +175,7 @@ Search the web using your configured provider.
|
||||
- `country` (optional): 2-letter country code for region-specific results (e.g., "DE", "US", "ALL"). If omitted, Brave chooses its default region.
|
||||
- `search_lang` (optional): ISO language code for search results (e.g., "de", "en", "fr")
|
||||
- `ui_lang` (optional): ISO language code for UI elements
|
||||
- `freshness` (optional): filter by discovery time
|
||||
- Brave: `pd`, `pw`, `pm`, `py`, or `YYYY-MM-DDtoYYYY-MM-DD`
|
||||
- Perplexity: `pd`, `pw`, `pm`, `py`
|
||||
- `freshness` (optional, Brave only): filter by discovery time (`pd`, `pw`, `pm`, `py`, or `YYYY-MM-DDtoYYYY-MM-DD`)
|
||||
|
||||
**Examples:**
|
||||
|
||||
|
||||
@@ -44,7 +44,6 @@ Channel options:
|
||||
Related global options:
|
||||
|
||||
- `gateway.port`, `gateway.bind`: WebSocket host/port.
|
||||
- `gateway.auth.mode`, `gateway.auth.token`, `gateway.auth.password`: WebSocket auth (token/password).
|
||||
- `gateway.auth.mode: "trusted-proxy"`: reverse-proxy auth for browser clients (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)).
|
||||
- `gateway.auth.mode`, `gateway.auth.token`, `gateway.auth.password`: WebSocket auth.
|
||||
- `gateway.remote.url`, `gateway.remote.token`, `gateway.remote.password`: remote gateway target.
|
||||
- `session.*`: session storage and main key defaults.
|
||||
|
||||
@@ -133,7 +133,7 @@ Hook 包可以附带依赖;它们将安装在 `~/.openclaw/hooks/<id>` 下。
|
||||
---
|
||||
name: my-hook
|
||||
description: "Short description of what this hook does"
|
||||
homepage: https://docs.openclaw.ai/automation/hooks#my-hook
|
||||
homepage: https://docs.openclaw.ai/hooks#my-hook
|
||||
metadata:
|
||||
{ "openclaw": { "emoji": "🔗", "events": ["command:new"], "requires": { "bins": ["node"] } } }
|
||||
---
|
||||
|
||||
@@ -724,7 +724,7 @@ Telegram 反应作为**单独的 `message_reaction` 事件**到达,而不是
|
||||
- `channels.telegram.groups.<id>.topics.<threadId>.requireMention`:每话题提及门控覆盖。
|
||||
- `channels.telegram.capabilities.inlineButtons`:`off | dm | group | all | allowlist`(默认:allowlist)。
|
||||
- `channels.telegram.accounts.<account>.capabilities.inlineButtons`:每账户覆盖。
|
||||
- `channels.telegram.replyToMode`:`off | first | all`(默认:`off`)。
|
||||
- `channels.telegram.replyToMode`:`off | first | all`(默认:`first`)。
|
||||
- `channels.telegram.textChunkLimit`:出站分块大小(字符)。
|
||||
- `channels.telegram.chunkMode`:`length`(默认)或 `newline` 在长度分块之前按空行(段落边界)分割。
|
||||
- `channels.telegram.linkPreview`:切换出站消息的链接预览(默认:true)。
|
||||
|
||||
@@ -96,7 +96,7 @@ Details:
|
||||
Source: openclaw-bundled
|
||||
Path: /path/to/openclaw/hooks/bundled/session-memory/HOOK.md
|
||||
Handler: /path/to/openclaw/hooks/bundled/session-memory/handler.ts
|
||||
Homepage: https://docs.openclaw.ai/automation/hooks#session-memory
|
||||
Homepage: https://docs.openclaw.ai/hooks#session-memory
|
||||
Events: command:new
|
||||
|
||||
Requirements:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bluebubbles",
|
||||
"version": "2026.2.13",
|
||||
"version": "2026.2.12",
|
||||
"description": "OpenClaw BlueBubbles channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { bluebubblesMessageActions } from "./actions.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
||||
@@ -42,15 +41,9 @@ vi.mock("./monitor.js", () => ({
|
||||
resolveBlueBubblesMessageId: vi.fn((id: string) => id),
|
||||
}));
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
isMacOS26OrHigher: vi.fn().mockReturnValue(false),
|
||||
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
describe("bluebubblesMessageActions", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe("listActions", () => {
|
||||
@@ -101,31 +94,6 @@ describe("bluebubblesMessageActions", () => {
|
||||
expect(actions).toContain("edit");
|
||||
expect(actions).toContain("unsend");
|
||||
});
|
||||
|
||||
it("hides private-api actions when private API is disabled", () => {
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
const actions = bluebubblesMessageActions.listActions({ cfg });
|
||||
expect(actions).toContain("sendAttachment");
|
||||
expect(actions).not.toContain("react");
|
||||
expect(actions).not.toContain("reply");
|
||||
expect(actions).not.toContain("sendWithEffect");
|
||||
expect(actions).not.toContain("edit");
|
||||
expect(actions).not.toContain("unsend");
|
||||
expect(actions).not.toContain("renameGroup");
|
||||
expect(actions).not.toContain("setGroupIcon");
|
||||
expect(actions).not.toContain("addParticipant");
|
||||
expect(actions).not.toContain("removeParticipant");
|
||||
expect(actions).not.toContain("leaveGroup");
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsAction", () => {
|
||||
@@ -221,26 +189,6 @@ describe("bluebubblesMessageActions", () => {
|
||||
).rejects.toThrow(/emoji/i);
|
||||
});
|
||||
|
||||
it("throws a private-api error for private-only actions when disabled", async () => {
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
},
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
bluebubblesMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: { emoji: "❤️", messageId: "msg-123", chatGuid: "iMessage;-;+15551234567" },
|
||||
cfg,
|
||||
accountId: null,
|
||||
}),
|
||||
).rejects.toThrow("requires Private API");
|
||||
});
|
||||
|
||||
it("throws when messageId is missing", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
leaveBlueBubblesChat,
|
||||
} from "./chat.js";
|
||||
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
|
||||
import { isMacOS26OrHigher } from "./probe.js";
|
||||
import { sendBlueBubblesReaction } from "./reactions.js";
|
||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
|
||||
@@ -71,18 +71,6 @@ function readBooleanParam(params: Record<string, unknown>, key: string): boolean
|
||||
|
||||
/** Supported action names for BlueBubbles */
|
||||
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
||||
const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
|
||||
"react",
|
||||
"edit",
|
||||
"unsend",
|
||||
"reply",
|
||||
"sendWithEffect",
|
||||
"renameGroup",
|
||||
"setGroupIcon",
|
||||
"addParticipant",
|
||||
"removeParticipant",
|
||||
"leaveGroup",
|
||||
]);
|
||||
|
||||
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: ({ cfg }) => {
|
||||
@@ -93,15 +81,11 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
const gate = createActionGate(cfg.channels?.bluebubbles?.actions);
|
||||
const actions = new Set<ChannelMessageActionName>();
|
||||
const macOS26 = isMacOS26OrHigher(account.accountId);
|
||||
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(account.accountId);
|
||||
for (const action of BLUEBUBBLES_ACTION_NAMES) {
|
||||
const spec = BLUEBUBBLES_ACTIONS[action];
|
||||
if (!spec?.gate) {
|
||||
continue;
|
||||
}
|
||||
if (privateApiStatus === false && PRIVATE_API_ACTIONS.has(action)) {
|
||||
continue;
|
||||
}
|
||||
if ("unsupportedOnMacOS26" in spec && spec.unsupportedOnMacOS26 && macOS26) {
|
||||
continue;
|
||||
}
|
||||
@@ -132,13 +116,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
const baseUrl = account.config.serverUrl?.trim();
|
||||
const password = account.config.password?.trim();
|
||||
const opts = { cfg: cfg, accountId: accountId ?? undefined };
|
||||
const assertPrivateApiEnabled = () => {
|
||||
if (getCachedBlueBubblesPrivateApiStatus(account.accountId) === false) {
|
||||
throw new Error(
|
||||
`BlueBubbles ${action} requires Private API, but it is disabled on the BlueBubbles server.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to resolve chatGuid from various params or session context
|
||||
const resolveChatGuid = async (): Promise<string> => {
|
||||
@@ -182,7 +159,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
|
||||
// Handle react action
|
||||
if (action === "react") {
|
||||
assertPrivateApiEnabled();
|
||||
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
||||
removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.",
|
||||
});
|
||||
@@ -217,7 +193,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
|
||||
// Handle edit action
|
||||
if (action === "edit") {
|
||||
assertPrivateApiEnabled();
|
||||
// Edit is not supported on macOS 26+
|
||||
if (isMacOS26OrHigher(accountId ?? undefined)) {
|
||||
throw new Error(
|
||||
@@ -259,7 +234,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
|
||||
// Handle unsend action
|
||||
if (action === "unsend") {
|
||||
assertPrivateApiEnabled();
|
||||
const rawMessageId = readStringParam(params, "messageId");
|
||||
if (!rawMessageId) {
|
||||
throw new Error(
|
||||
@@ -281,7 +255,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
|
||||
// Handle reply action
|
||||
if (action === "reply") {
|
||||
assertPrivateApiEnabled();
|
||||
const rawMessageId = readStringParam(params, "messageId");
|
||||
const text = readMessageText(params);
|
||||
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
|
||||
@@ -316,7 +289,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
|
||||
// Handle sendWithEffect action
|
||||
if (action === "sendWithEffect") {
|
||||
assertPrivateApiEnabled();
|
||||
const text = readMessageText(params);
|
||||
const to = readStringParam(params, "to") ?? readStringParam(params, "target");
|
||||
const effectId = readStringParam(params, "effectId") ?? readStringParam(params, "effect");
|
||||
@@ -349,7 +321,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
|
||||
// Handle renameGroup action
|
||||
if (action === "renameGroup") {
|
||||
assertPrivateApiEnabled();
|
||||
const resolvedChatGuid = await resolveChatGuid();
|
||||
const displayName = readStringParam(params, "displayName") ?? readStringParam(params, "name");
|
||||
if (!displayName) {
|
||||
@@ -363,7 +334,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
|
||||
// Handle setGroupIcon action
|
||||
if (action === "setGroupIcon") {
|
||||
assertPrivateApiEnabled();
|
||||
const resolvedChatGuid = await resolveChatGuid();
|
||||
const base64Buffer = readStringParam(params, "buffer");
|
||||
const filename =
|
||||
@@ -391,7 +361,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
|
||||
// Handle addParticipant action
|
||||
if (action === "addParticipant") {
|
||||
assertPrivateApiEnabled();
|
||||
const resolvedChatGuid = await resolveChatGuid();
|
||||
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
|
||||
if (!address) {
|
||||
@@ -405,7 +374,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
|
||||
// Handle removeParticipant action
|
||||
if (action === "removeParticipant") {
|
||||
assertPrivateApiEnabled();
|
||||
const resolvedChatGuid = await resolveChatGuid();
|
||||
const address = readStringParam(params, "address") ?? readStringParam(params, "participant");
|
||||
if (!address) {
|
||||
@@ -419,7 +387,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
|
||||
// Handle leaveGroup action
|
||||
if (action === "leaveGroup") {
|
||||
assertPrivateApiEnabled();
|
||||
const resolvedChatGuid = await resolveChatGuid();
|
||||
|
||||
await leaveBlueBubblesChat(resolvedChatGuid, opts);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { BlueBubblesAttachment } from "./types.js";
|
||||
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
||||
@@ -15,18 +14,12 @@ vi.mock("./accounts.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
describe("downloadBlueBubblesAttachment", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -249,8 +242,6 @@ describe("sendBlueBubblesAttachment", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -351,27 +342,4 @@ describe("sendBlueBubblesAttachment", () => {
|
||||
expect(bodyText).toContain('filename="evil.mp3"');
|
||||
expect(bodyText).toContain('name="evil.mp3"');
|
||||
});
|
||||
|
||||
it("downgrades attachment reply threading when private API is disabled", async () => {
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-4" })),
|
||||
});
|
||||
|
||||
await sendBlueBubblesAttachment({
|
||||
to: "chat_guid:iMessage;-;+15551234567",
|
||||
buffer: new Uint8Array([1, 2, 3]),
|
||||
filename: "photo.jpg",
|
||||
contentType: "image/jpeg",
|
||||
replyToMessageGuid: "reply-guid-123",
|
||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
||||
});
|
||||
|
||||
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
||||
const bodyText = decodeBody(body);
|
||||
expect(bodyText).not.toContain('name="method"');
|
||||
expect(bodyText).not.toContain('name="selectedMessageGuid"');
|
||||
expect(bodyText).not.toContain('name="partIndex"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { resolveChatGuidForTarget } from "./send.js";
|
||||
import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
|
||||
import {
|
||||
@@ -65,7 +64,7 @@ function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
||||
if (!password) {
|
||||
throw new Error("BlueBubbles password is required");
|
||||
}
|
||||
return { baseUrl, password, accountId: account.accountId };
|
||||
return { baseUrl, password };
|
||||
}
|
||||
|
||||
export async function downloadBlueBubblesAttachment(
|
||||
@@ -170,8 +169,7 @@ export async function sendBlueBubblesAttachment(params: {
|
||||
const fallbackName = wantsVoice ? "Audio Message" : "attachment";
|
||||
filename = sanitizeFilename(filename, fallbackName);
|
||||
contentType = contentType?.trim() || undefined;
|
||||
const { baseUrl, password, accountId } = resolveAccount(opts);
|
||||
const privateApiStatus = getCachedBlueBubblesPrivateApiStatus(accountId);
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
|
||||
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
|
||||
const isAudioMessage = wantsVoice;
|
||||
@@ -240,9 +238,7 @@ export async function sendBlueBubblesAttachment(params: {
|
||||
addField("chatGuid", chatGuid);
|
||||
addField("name", filename);
|
||||
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
|
||||
if (privateApiStatus !== false) {
|
||||
addField("method", "private-api");
|
||||
}
|
||||
addField("method", "private-api");
|
||||
|
||||
// Add isAudioMessage flag for voice memos
|
||||
if (isAudioMessage) {
|
||||
@@ -250,7 +246,7 @@ export async function sendBlueBubblesAttachment(params: {
|
||||
}
|
||||
|
||||
const trimmedReplyTo = replyToMessageGuid?.trim();
|
||||
if (trimmedReplyTo && privateApiStatus !== false) {
|
||||
if (trimmedReplyTo) {
|
||||
addField("selectedMessageGuid", trimmedReplyTo);
|
||||
addField("partIndex", typeof replyToPartIndex === "number" ? String(replyToPartIndex) : "0");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
||||
@@ -14,18 +13,12 @@ vi.mock("./accounts.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
describe("chat", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -80,17 +73,6 @@ describe("chat", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not send read receipt when private API is disabled", async () => {
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
|
||||
|
||||
await markBlueBubblesChatRead("iMessage;-;+15551234567", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
});
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes password in URL query", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@@ -208,17 +190,6 @@ describe("chat", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not send typing when private API is disabled", async () => {
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
|
||||
|
||||
await sendBlueBubblesTyping("iMessage;-;+15551234567", true, {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends typing stop with DELETE method", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
@@ -377,17 +348,6 @@ describe("chat", () => {
|
||||
).rejects.toThrow("password is required");
|
||||
});
|
||||
|
||||
it("throws when private API is disabled", async () => {
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
|
||||
await expect(
|
||||
setGroupIconBlueBubbles("chat-guid", new Uint8Array([1, 2, 3]), "icon.png", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
).rejects.toThrow("requires Private API");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets group icon successfully", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
export type BlueBubblesChatOpts = {
|
||||
@@ -26,15 +25,7 @@ function resolveAccount(params: BlueBubblesChatOpts) {
|
||||
if (!password) {
|
||||
throw new Error("BlueBubbles password is required");
|
||||
}
|
||||
return { baseUrl, password, accountId: account.accountId };
|
||||
}
|
||||
|
||||
function assertPrivateApiEnabled(accountId: string, feature: string): void {
|
||||
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
|
||||
throw new Error(
|
||||
`BlueBubbles ${feature} requires Private API, but it is disabled on the BlueBubbles server.`,
|
||||
);
|
||||
}
|
||||
return { baseUrl, password };
|
||||
}
|
||||
|
||||
export async function markBlueBubblesChatRead(
|
||||
@@ -45,10 +36,7 @@ export async function markBlueBubblesChatRead(
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const { baseUrl, password, accountId } = resolveAccount(opts);
|
||||
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
|
||||
return;
|
||||
}
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`,
|
||||
@@ -70,10 +58,7 @@ export async function sendBlueBubblesTyping(
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
const { baseUrl, password, accountId } = resolveAccount(opts);
|
||||
if (getCachedBlueBubblesPrivateApiStatus(accountId) === false) {
|
||||
return;
|
||||
}
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`,
|
||||
@@ -108,8 +93,7 @@ export async function editBlueBubblesMessage(
|
||||
throw new Error("BlueBubbles edit requires newText");
|
||||
}
|
||||
|
||||
const { baseUrl, password, accountId } = resolveAccount(opts);
|
||||
assertPrivateApiEnabled(accountId, "edit");
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/edit`,
|
||||
@@ -151,8 +135,7 @@ export async function unsendBlueBubblesMessage(
|
||||
throw new Error("BlueBubbles unsend requires messageGuid");
|
||||
}
|
||||
|
||||
const { baseUrl, password, accountId } = resolveAccount(opts);
|
||||
assertPrivateApiEnabled(accountId, "unsend");
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/message/${encodeURIComponent(trimmedGuid)}/unsend`,
|
||||
@@ -192,8 +175,7 @@ export async function renameBlueBubblesChat(
|
||||
throw new Error("BlueBubbles rename requires chatGuid");
|
||||
}
|
||||
|
||||
const { baseUrl, password, accountId } = resolveAccount(opts);
|
||||
assertPrivateApiEnabled(accountId, "renameGroup");
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}`,
|
||||
@@ -233,8 +215,7 @@ export async function addBlueBubblesParticipant(
|
||||
throw new Error("BlueBubbles addParticipant requires address");
|
||||
}
|
||||
|
||||
const { baseUrl, password, accountId } = resolveAccount(opts);
|
||||
assertPrivateApiEnabled(accountId, "addParticipant");
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
|
||||
@@ -274,8 +255,7 @@ export async function removeBlueBubblesParticipant(
|
||||
throw new Error("BlueBubbles removeParticipant requires address");
|
||||
}
|
||||
|
||||
const { baseUrl, password, accountId } = resolveAccount(opts);
|
||||
assertPrivateApiEnabled(accountId, "removeParticipant");
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/participant`,
|
||||
@@ -312,8 +292,7 @@ export async function leaveBlueBubblesChat(
|
||||
throw new Error("BlueBubbles leaveChat requires chatGuid");
|
||||
}
|
||||
|
||||
const { baseUrl, password, accountId } = resolveAccount(opts);
|
||||
assertPrivateApiEnabled(accountId, "leaveGroup");
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/leave`,
|
||||
@@ -346,8 +325,7 @@ export async function setGroupIconBlueBubbles(
|
||||
throw new Error("BlueBubbles setGroupIcon requires image buffer");
|
||||
}
|
||||
|
||||
const { baseUrl, password, accountId } = resolveAccount(opts);
|
||||
assertPrivateApiEnabled(accountId, "setGroupIcon");
|
||||
const { baseUrl, password } = resolveAccount(opts);
|
||||
const url = buildBlueBubblesApiUrl({
|
||||
baseUrl,
|
||||
path: `/api/v1/chat/${encodeURIComponent(trimmedGuid)}/icon`,
|
||||
|
||||
@@ -1,842 +0,0 @@
|
||||
import type { BlueBubblesAttachment } from "./types.js";
|
||||
import { normalizeBlueBubblesHandle } from "./targets.js";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown> | null, key: string): string | undefined {
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const value = record[key];
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function readNumber(record: Record<string, unknown> | null, key: string): number | undefined {
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const value = record[key];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function readBoolean(record: Record<string, unknown> | null, key: string): boolean | undefined {
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const value = record[key];
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
|
||||
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
||||
if (!record) {
|
||||
return undefined;
|
||||
}
|
||||
const value = record[key];
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractAttachments(message: Record<string, unknown>): BlueBubblesAttachment[] {
|
||||
const raw = message["attachments"];
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
const out: BlueBubblesAttachment[] = [];
|
||||
for (const entry of raw) {
|
||||
const record = asRecord(entry);
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
out.push({
|
||||
guid: readString(record, "guid"),
|
||||
uti: readString(record, "uti"),
|
||||
mimeType: readString(record, "mimeType") ?? readString(record, "mime_type"),
|
||||
transferName: readString(record, "transferName") ?? readString(record, "transfer_name"),
|
||||
totalBytes: readNumberLike(record, "totalBytes") ?? readNumberLike(record, "total_bytes"),
|
||||
height: readNumberLike(record, "height"),
|
||||
width: readNumberLike(record, "width"),
|
||||
originalROWID: readNumberLike(record, "originalROWID") ?? readNumberLike(record, "rowid"),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildAttachmentPlaceholder(attachments: BlueBubblesAttachment[]): string {
|
||||
if (attachments.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const mimeTypes = attachments.map((entry) => entry.mimeType ?? "");
|
||||
const allImages = mimeTypes.every((entry) => entry.startsWith("image/"));
|
||||
const allVideos = mimeTypes.every((entry) => entry.startsWith("video/"));
|
||||
const allAudio = mimeTypes.every((entry) => entry.startsWith("audio/"));
|
||||
const tag = allImages
|
||||
? "<media:image>"
|
||||
: allVideos
|
||||
? "<media:video>"
|
||||
: allAudio
|
||||
? "<media:audio>"
|
||||
: "<media:attachment>";
|
||||
const label = allImages ? "image" : allVideos ? "video" : allAudio ? "audio" : "file";
|
||||
const suffix = attachments.length === 1 ? label : `${label}s`;
|
||||
return `${tag} (${attachments.length} ${suffix})`;
|
||||
}
|
||||
|
||||
export function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
|
||||
const attachmentPlaceholder = buildAttachmentPlaceholder(message.attachments ?? []);
|
||||
if (attachmentPlaceholder) {
|
||||
return attachmentPlaceholder;
|
||||
}
|
||||
if (message.balloonBundleId) {
|
||||
return "<media:sticker>";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Returns inline reply tag like "[[reply_to:4]]" for prepending to message body
|
||||
export function formatReplyTag(message: {
|
||||
replyToId?: string;
|
||||
replyToShortId?: string;
|
||||
}): string | null {
|
||||
// Prefer short ID
|
||||
const rawId = message.replyToShortId || message.replyToId;
|
||||
if (!rawId) {
|
||||
return null;
|
||||
}
|
||||
return `[[reply_to:${rawId}]]`;
|
||||
}
|
||||
|
||||
function extractReplyMetadata(message: Record<string, unknown>): {
|
||||
replyToId?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
} {
|
||||
const replyRaw =
|
||||
message["replyTo"] ??
|
||||
message["reply_to"] ??
|
||||
message["replyToMessage"] ??
|
||||
message["reply_to_message"] ??
|
||||
message["repliedMessage"] ??
|
||||
message["quotedMessage"] ??
|
||||
message["associatedMessage"] ??
|
||||
message["reply"];
|
||||
const replyRecord = asRecord(replyRaw);
|
||||
const replyHandle =
|
||||
asRecord(replyRecord?.["handle"]) ?? asRecord(replyRecord?.["sender"]) ?? null;
|
||||
const replySenderRaw =
|
||||
readString(replyHandle, "address") ??
|
||||
readString(replyHandle, "handle") ??
|
||||
readString(replyHandle, "id") ??
|
||||
readString(replyRecord, "senderId") ??
|
||||
readString(replyRecord, "sender") ??
|
||||
readString(replyRecord, "from");
|
||||
const normalizedSender = replySenderRaw
|
||||
? normalizeBlueBubblesHandle(replySenderRaw) || replySenderRaw.trim()
|
||||
: undefined;
|
||||
|
||||
const replyToBody =
|
||||
readString(replyRecord, "text") ??
|
||||
readString(replyRecord, "body") ??
|
||||
readString(replyRecord, "message") ??
|
||||
readString(replyRecord, "subject") ??
|
||||
undefined;
|
||||
|
||||
const directReplyId =
|
||||
readString(message, "replyToMessageGuid") ??
|
||||
readString(message, "replyToGuid") ??
|
||||
readString(message, "replyGuid") ??
|
||||
readString(message, "selectedMessageGuid") ??
|
||||
readString(message, "selectedMessageId") ??
|
||||
readString(message, "replyToMessageId") ??
|
||||
readString(message, "replyId") ??
|
||||
readString(replyRecord, "guid") ??
|
||||
readString(replyRecord, "id") ??
|
||||
readString(replyRecord, "messageId");
|
||||
|
||||
const associatedType =
|
||||
readNumberLike(message, "associatedMessageType") ??
|
||||
readNumberLike(message, "associated_message_type");
|
||||
const associatedGuid =
|
||||
readString(message, "associatedMessageGuid") ??
|
||||
readString(message, "associated_message_guid") ??
|
||||
readString(message, "associatedMessageId");
|
||||
const isReactionAssociation =
|
||||
typeof associatedType === "number" && REACTION_TYPE_MAP.has(associatedType);
|
||||
|
||||
const replyToId = directReplyId ?? (!isReactionAssociation ? associatedGuid : undefined);
|
||||
const threadOriginatorGuid = readString(message, "threadOriginatorGuid");
|
||||
const messageGuid = readString(message, "guid");
|
||||
const fallbackReplyId =
|
||||
!replyToId && threadOriginatorGuid && threadOriginatorGuid !== messageGuid
|
||||
? threadOriginatorGuid
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
replyToId: (replyToId ?? fallbackReplyId)?.trim() || undefined,
|
||||
replyToBody: replyToBody?.trim() || undefined,
|
||||
replyToSender: normalizedSender || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function readFirstChatRecord(message: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const chats = message["chats"];
|
||||
if (!Array.isArray(chats) || chats.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const first = chats[0];
|
||||
return asRecord(first);
|
||||
}
|
||||
|
||||
function normalizeParticipantEntry(entry: unknown): BlueBubblesParticipant | null {
|
||||
if (typeof entry === "string" || typeof entry === "number") {
|
||||
const raw = String(entry).trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const normalized = normalizeBlueBubblesHandle(raw) || raw;
|
||||
return normalized ? { id: normalized } : null;
|
||||
}
|
||||
const record = asRecord(entry);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
const nestedHandle =
|
||||
asRecord(record["handle"]) ?? asRecord(record["sender"]) ?? asRecord(record["contact"]) ?? null;
|
||||
const idRaw =
|
||||
readString(record, "address") ??
|
||||
readString(record, "handle") ??
|
||||
readString(record, "id") ??
|
||||
readString(record, "phoneNumber") ??
|
||||
readString(record, "phone_number") ??
|
||||
readString(record, "email") ??
|
||||
readString(nestedHandle, "address") ??
|
||||
readString(nestedHandle, "handle") ??
|
||||
readString(nestedHandle, "id");
|
||||
const nameRaw =
|
||||
readString(record, "displayName") ??
|
||||
readString(record, "name") ??
|
||||
readString(record, "title") ??
|
||||
readString(nestedHandle, "displayName") ??
|
||||
readString(nestedHandle, "name");
|
||||
const normalizedId = idRaw ? normalizeBlueBubblesHandle(idRaw) || idRaw.trim() : "";
|
||||
if (!normalizedId) {
|
||||
return null;
|
||||
}
|
||||
const name = nameRaw?.trim() || undefined;
|
||||
return { id: normalizedId, name };
|
||||
}
|
||||
|
||||
function normalizeParticipantList(raw: unknown): BlueBubblesParticipant[] {
|
||||
if (!Array.isArray(raw) || raw.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const output: BlueBubblesParticipant[] = [];
|
||||
for (const entry of raw) {
|
||||
const normalized = normalizeParticipantEntry(entry);
|
||||
if (!normalized?.id) {
|
||||
continue;
|
||||
}
|
||||
const key = normalized.id.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
output.push(normalized);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function formatGroupMembers(params: {
|
||||
participants?: BlueBubblesParticipant[];
|
||||
fallback?: BlueBubblesParticipant;
|
||||
}): string | undefined {
|
||||
const seen = new Set<string>();
|
||||
const ordered: BlueBubblesParticipant[] = [];
|
||||
for (const entry of params.participants ?? []) {
|
||||
if (!entry?.id) {
|
||||
continue;
|
||||
}
|
||||
const key = entry.id.toLowerCase();
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
ordered.push(entry);
|
||||
}
|
||||
if (ordered.length === 0 && params.fallback?.id) {
|
||||
ordered.push(params.fallback);
|
||||
}
|
||||
if (ordered.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return ordered.map((entry) => (entry.name ? `${entry.name} (${entry.id})` : entry.id)).join(", ");
|
||||
}
|
||||
|
||||
export function resolveGroupFlagFromChatGuid(chatGuid?: string | null): boolean | undefined {
|
||||
const guid = chatGuid?.trim();
|
||||
if (!guid) {
|
||||
return undefined;
|
||||
}
|
||||
const parts = guid.split(";");
|
||||
if (parts.length >= 3) {
|
||||
if (parts[1] === "+") {
|
||||
return true;
|
||||
}
|
||||
if (parts[1] === "-") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (guid.includes(";+;")) {
|
||||
return true;
|
||||
}
|
||||
if (guid.includes(";-;")) {
|
||||
return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractChatIdentifierFromChatGuid(chatGuid?: string | null): string | undefined {
|
||||
const guid = chatGuid?.trim();
|
||||
if (!guid) {
|
||||
return undefined;
|
||||
}
|
||||
const parts = guid.split(";");
|
||||
if (parts.length < 3) {
|
||||
return undefined;
|
||||
}
|
||||
const identifier = parts[2]?.trim();
|
||||
return identifier || undefined;
|
||||
}
|
||||
|
||||
export function formatGroupAllowlistEntry(params: {
|
||||
chatGuid?: string;
|
||||
chatId?: number;
|
||||
chatIdentifier?: string;
|
||||
}): string | null {
|
||||
const guid = params.chatGuid?.trim();
|
||||
if (guid) {
|
||||
return `chat_guid:${guid}`;
|
||||
}
|
||||
const chatId = params.chatId;
|
||||
if (typeof chatId === "number" && Number.isFinite(chatId)) {
|
||||
return `chat_id:${chatId}`;
|
||||
}
|
||||
const identifier = params.chatIdentifier?.trim();
|
||||
if (identifier) {
|
||||
return `chat_identifier:${identifier}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type BlueBubblesParticipant = {
|
||||
id: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export type NormalizedWebhookMessage = {
|
||||
text: string;
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
messageId?: string;
|
||||
timestamp?: number;
|
||||
isGroup: boolean;
|
||||
chatId?: number;
|
||||
chatGuid?: string;
|
||||
chatIdentifier?: string;
|
||||
chatName?: string;
|
||||
fromMe?: boolean;
|
||||
attachments?: BlueBubblesAttachment[];
|
||||
balloonBundleId?: string;
|
||||
associatedMessageGuid?: string;
|
||||
associatedMessageType?: number;
|
||||
associatedMessageEmoji?: string;
|
||||
isTapback?: boolean;
|
||||
participants?: BlueBubblesParticipant[];
|
||||
replyToId?: string;
|
||||
replyToBody?: string;
|
||||
replyToSender?: string;
|
||||
};
|
||||
|
||||
export type NormalizedWebhookReaction = {
|
||||
action: "added" | "removed";
|
||||
emoji: string;
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
messageId: string;
|
||||
timestamp?: number;
|
||||
isGroup: boolean;
|
||||
chatId?: number;
|
||||
chatGuid?: string;
|
||||
chatIdentifier?: string;
|
||||
chatName?: string;
|
||||
fromMe?: boolean;
|
||||
};
|
||||
|
||||
const REACTION_TYPE_MAP = new Map<number, { emoji: string; action: "added" | "removed" }>([
|
||||
[2000, { emoji: "❤️", action: "added" }],
|
||||
[2001, { emoji: "👍", action: "added" }],
|
||||
[2002, { emoji: "👎", action: "added" }],
|
||||
[2003, { emoji: "😂", action: "added" }],
|
||||
[2004, { emoji: "‼️", action: "added" }],
|
||||
[2005, { emoji: "❓", action: "added" }],
|
||||
[3000, { emoji: "❤️", action: "removed" }],
|
||||
[3001, { emoji: "👍", action: "removed" }],
|
||||
[3002, { emoji: "👎", action: "removed" }],
|
||||
[3003, { emoji: "😂", action: "removed" }],
|
||||
[3004, { emoji: "‼️", action: "removed" }],
|
||||
[3005, { emoji: "❓", action: "removed" }],
|
||||
]);
|
||||
|
||||
// Maps tapback text patterns (e.g., "Loved", "Liked") to emoji + action
|
||||
const TAPBACK_TEXT_MAP = new Map<string, { emoji: string; action: "added" | "removed" }>([
|
||||
["loved", { emoji: "❤️", action: "added" }],
|
||||
["liked", { emoji: "👍", action: "added" }],
|
||||
["disliked", { emoji: "👎", action: "added" }],
|
||||
["laughed at", { emoji: "😂", action: "added" }],
|
||||
["emphasized", { emoji: "‼️", action: "added" }],
|
||||
["questioned", { emoji: "❓", action: "added" }],
|
||||
// Removal patterns (e.g., "Removed a heart from")
|
||||
["removed a heart from", { emoji: "❤️", action: "removed" }],
|
||||
["removed a like from", { emoji: "👍", action: "removed" }],
|
||||
["removed a dislike from", { emoji: "👎", action: "removed" }],
|
||||
["removed a laugh from", { emoji: "😂", action: "removed" }],
|
||||
["removed an emphasis from", { emoji: "‼️", action: "removed" }],
|
||||
["removed a question from", { emoji: "❓", action: "removed" }],
|
||||
]);
|
||||
|
||||
const TAPBACK_EMOJI_REGEX =
|
||||
/(?:\p{Regional_Indicator}{2})|(?:[0-9#*]\uFE0F?\u20E3)|(?:\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u;
|
||||
|
||||
function extractFirstEmoji(text: string): string | null {
|
||||
const match = text.match(TAPBACK_EMOJI_REGEX);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
function extractQuotedTapbackText(text: string): string | null {
|
||||
const match = text.match(/[“"]([^”"]+)[”"]/s);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function isTapbackAssociatedType(type: number | undefined): boolean {
|
||||
return typeof type === "number" && Number.isFinite(type) && type >= 2000 && type < 4000;
|
||||
}
|
||||
|
||||
function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined {
|
||||
if (typeof type !== "number" || !Number.isFinite(type)) {
|
||||
return undefined;
|
||||
}
|
||||
if (type >= 3000 && type < 4000) {
|
||||
return "removed";
|
||||
}
|
||||
if (type >= 2000 && type < 3000) {
|
||||
return "added";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveTapbackContext(message: NormalizedWebhookMessage): {
|
||||
emojiHint?: string;
|
||||
actionHint?: "added" | "removed";
|
||||
replyToId?: string;
|
||||
} | null {
|
||||
const associatedType = message.associatedMessageType;
|
||||
const hasTapbackType = isTapbackAssociatedType(associatedType);
|
||||
const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback);
|
||||
if (!hasTapbackType && !hasTapbackMarker) {
|
||||
return null;
|
||||
}
|
||||
const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined;
|
||||
const actionHint = resolveTapbackActionHint(associatedType);
|
||||
const emojiHint =
|
||||
message.associatedMessageEmoji?.trim() || REACTION_TYPE_MAP.get(associatedType ?? -1)?.emoji;
|
||||
return { emojiHint, actionHint, replyToId };
|
||||
}
|
||||
|
||||
// Detects tapback text patterns like 'Loved "message"' and converts to structured format
|
||||
export function parseTapbackText(params: {
|
||||
text: string;
|
||||
emojiHint?: string;
|
||||
actionHint?: "added" | "removed";
|
||||
requireQuoted?: boolean;
|
||||
}): {
|
||||
emoji: string;
|
||||
action: "added" | "removed";
|
||||
quotedText: string;
|
||||
} | null {
|
||||
const trimmed = params.text.trim();
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
|
||||
if (lower.startsWith(pattern)) {
|
||||
// Extract quoted text if present (e.g., 'Loved "hello"' -> "hello")
|
||||
const afterPattern = trimmed.slice(pattern.length).trim();
|
||||
if (params.requireQuoted) {
|
||||
const strictMatch = afterPattern.match(/^[“"](.+)[”"]$/s);
|
||||
if (!strictMatch) {
|
||||
return null;
|
||||
}
|
||||
return { emoji, action, quotedText: strictMatch[1] };
|
||||
}
|
||||
const quotedText =
|
||||
extractQuotedTapbackText(afterPattern) ?? extractQuotedTapbackText(trimmed) ?? afterPattern;
|
||||
return { emoji, action, quotedText };
|
||||
}
|
||||
}
|
||||
|
||||
if (lower.startsWith("reacted")) {
|
||||
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
||||
if (!emoji) {
|
||||
return null;
|
||||
}
|
||||
const quotedText = extractQuotedTapbackText(trimmed);
|
||||
if (params.requireQuoted && !quotedText) {
|
||||
return null;
|
||||
}
|
||||
const fallback = trimmed.slice("reacted".length).trim();
|
||||
return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
|
||||
}
|
||||
|
||||
if (lower.startsWith("removed")) {
|
||||
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
|
||||
if (!emoji) {
|
||||
return null;
|
||||
}
|
||||
const quotedText = extractQuotedTapbackText(trimmed);
|
||||
if (params.requireQuoted && !quotedText) {
|
||||
return null;
|
||||
}
|
||||
const fallback = trimmed.slice("removed".length).trim();
|
||||
return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractMessagePayload(payload: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const dataRaw = payload.data ?? payload.payload ?? payload.event;
|
||||
const data =
|
||||
asRecord(dataRaw) ??
|
||||
(typeof dataRaw === "string" ? (asRecord(JSON.parse(dataRaw)) ?? null) : null);
|
||||
const messageRaw = payload.message ?? data?.message ?? data;
|
||||
const message =
|
||||
asRecord(messageRaw) ??
|
||||
(typeof messageRaw === "string" ? (asRecord(JSON.parse(messageRaw)) ?? null) : null);
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
export function normalizeWebhookMessage(
|
||||
payload: Record<string, unknown>,
|
||||
): NormalizedWebhookMessage | null {
|
||||
const message = extractMessagePayload(payload);
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text =
|
||||
readString(message, "text") ??
|
||||
readString(message, "body") ??
|
||||
readString(message, "subject") ??
|
||||
"";
|
||||
|
||||
const handleValue = message.handle ?? message.sender;
|
||||
const handle =
|
||||
asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
|
||||
const senderId =
|
||||
readString(handle, "address") ??
|
||||
readString(handle, "handle") ??
|
||||
readString(handle, "id") ??
|
||||
readString(message, "senderId") ??
|
||||
readString(message, "sender") ??
|
||||
readString(message, "from") ??
|
||||
"";
|
||||
|
||||
const senderName =
|
||||
readString(handle, "displayName") ??
|
||||
readString(handle, "name") ??
|
||||
readString(message, "senderName") ??
|
||||
undefined;
|
||||
|
||||
const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null;
|
||||
const chatFromList = readFirstChatRecord(message);
|
||||
const chatGuid =
|
||||
readString(message, "chatGuid") ??
|
||||
readString(message, "chat_guid") ??
|
||||
readString(chat, "chatGuid") ??
|
||||
readString(chat, "chat_guid") ??
|
||||
readString(chat, "guid") ??
|
||||
readString(chatFromList, "chatGuid") ??
|
||||
readString(chatFromList, "chat_guid") ??
|
||||
readString(chatFromList, "guid");
|
||||
const chatIdentifier =
|
||||
readString(message, "chatIdentifier") ??
|
||||
readString(message, "chat_identifier") ??
|
||||
readString(chat, "chatIdentifier") ??
|
||||
readString(chat, "chat_identifier") ??
|
||||
readString(chat, "identifier") ??
|
||||
readString(chatFromList, "chatIdentifier") ??
|
||||
readString(chatFromList, "chat_identifier") ??
|
||||
readString(chatFromList, "identifier") ??
|
||||
extractChatIdentifierFromChatGuid(chatGuid);
|
||||
const chatId =
|
||||
readNumberLike(message, "chatId") ??
|
||||
readNumberLike(message, "chat_id") ??
|
||||
readNumberLike(chat, "chatId") ??
|
||||
readNumberLike(chat, "chat_id") ??
|
||||
readNumberLike(chat, "id") ??
|
||||
readNumberLike(chatFromList, "chatId") ??
|
||||
readNumberLike(chatFromList, "chat_id") ??
|
||||
readNumberLike(chatFromList, "id");
|
||||
const chatName =
|
||||
readString(message, "chatName") ??
|
||||
readString(chat, "displayName") ??
|
||||
readString(chat, "name") ??
|
||||
readString(chatFromList, "displayName") ??
|
||||
readString(chatFromList, "name") ??
|
||||
undefined;
|
||||
|
||||
const chatParticipants = chat ? chat["participants"] : undefined;
|
||||
const messageParticipants = message["participants"];
|
||||
const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined;
|
||||
const participants = Array.isArray(chatParticipants)
|
||||
? chatParticipants
|
||||
: Array.isArray(messageParticipants)
|
||||
? messageParticipants
|
||||
: Array.isArray(chatsParticipants)
|
||||
? chatsParticipants
|
||||
: [];
|
||||
const normalizedParticipants = normalizeParticipantList(participants);
|
||||
const participantsCount = participants.length;
|
||||
const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
|
||||
const explicitIsGroup =
|
||||
readBoolean(message, "isGroup") ??
|
||||
readBoolean(message, "is_group") ??
|
||||
readBoolean(chat, "isGroup") ??
|
||||
readBoolean(message, "group");
|
||||
const isGroup =
|
||||
typeof groupFromChatGuid === "boolean"
|
||||
? groupFromChatGuid
|
||||
: (explicitIsGroup ?? participantsCount > 2);
|
||||
|
||||
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
|
||||
const messageId =
|
||||
readString(message, "guid") ??
|
||||
readString(message, "id") ??
|
||||
readString(message, "messageId") ??
|
||||
undefined;
|
||||
const balloonBundleId = readString(message, "balloonBundleId");
|
||||
const associatedMessageGuid =
|
||||
readString(message, "associatedMessageGuid") ??
|
||||
readString(message, "associated_message_guid") ??
|
||||
readString(message, "associatedMessageId") ??
|
||||
undefined;
|
||||
const associatedMessageType =
|
||||
readNumberLike(message, "associatedMessageType") ??
|
||||
readNumberLike(message, "associated_message_type");
|
||||
const associatedMessageEmoji =
|
||||
readString(message, "associatedMessageEmoji") ??
|
||||
readString(message, "associated_message_emoji") ??
|
||||
readString(message, "reactionEmoji") ??
|
||||
readString(message, "reaction_emoji") ??
|
||||
undefined;
|
||||
const isTapback =
|
||||
readBoolean(message, "isTapback") ??
|
||||
readBoolean(message, "is_tapback") ??
|
||||
readBoolean(message, "tapback") ??
|
||||
undefined;
|
||||
|
||||
const timestampRaw =
|
||||
readNumber(message, "date") ??
|
||||
readNumber(message, "dateCreated") ??
|
||||
readNumber(message, "timestamp");
|
||||
const timestamp =
|
||||
typeof timestampRaw === "number"
|
||||
? timestampRaw > 1_000_000_000_000
|
||||
? timestampRaw
|
||||
: timestampRaw * 1000
|
||||
: undefined;
|
||||
|
||||
const normalizedSender = normalizeBlueBubblesHandle(senderId);
|
||||
if (!normalizedSender) {
|
||||
return null;
|
||||
}
|
||||
const replyMetadata = extractReplyMetadata(message);
|
||||
|
||||
return {
|
||||
text,
|
||||
senderId: normalizedSender,
|
||||
senderName,
|
||||
messageId,
|
||||
timestamp,
|
||||
isGroup,
|
||||
chatId,
|
||||
chatGuid,
|
||||
chatIdentifier,
|
||||
chatName,
|
||||
fromMe,
|
||||
attachments: extractAttachments(message),
|
||||
balloonBundleId,
|
||||
associatedMessageGuid,
|
||||
associatedMessageType,
|
||||
associatedMessageEmoji,
|
||||
isTapback,
|
||||
participants: normalizedParticipants,
|
||||
replyToId: replyMetadata.replyToId,
|
||||
replyToBody: replyMetadata.replyToBody,
|
||||
replyToSender: replyMetadata.replyToSender,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWebhookReaction(
|
||||
payload: Record<string, unknown>,
|
||||
): NormalizedWebhookReaction | null {
|
||||
const message = extractMessagePayload(payload);
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const associatedGuid =
|
||||
readString(message, "associatedMessageGuid") ??
|
||||
readString(message, "associated_message_guid") ??
|
||||
readString(message, "associatedMessageId");
|
||||
const associatedType =
|
||||
readNumberLike(message, "associatedMessageType") ??
|
||||
readNumberLike(message, "associated_message_type");
|
||||
if (!associatedGuid || associatedType === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mapping = REACTION_TYPE_MAP.get(associatedType);
|
||||
const associatedEmoji =
|
||||
readString(message, "associatedMessageEmoji") ??
|
||||
readString(message, "associated_message_emoji") ??
|
||||
readString(message, "reactionEmoji") ??
|
||||
readString(message, "reaction_emoji");
|
||||
const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
|
||||
const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
|
||||
|
||||
const handleValue = message.handle ?? message.sender;
|
||||
const handle =
|
||||
asRecord(handleValue) ?? (typeof handleValue === "string" ? { address: handleValue } : null);
|
||||
const senderId =
|
||||
readString(handle, "address") ??
|
||||
readString(handle, "handle") ??
|
||||
readString(handle, "id") ??
|
||||
readString(message, "senderId") ??
|
||||
readString(message, "sender") ??
|
||||
readString(message, "from") ??
|
||||
"";
|
||||
const senderName =
|
||||
readString(handle, "displayName") ??
|
||||
readString(handle, "name") ??
|
||||
readString(message, "senderName") ??
|
||||
undefined;
|
||||
|
||||
const chat = asRecord(message.chat) ?? asRecord(message.conversation) ?? null;
|
||||
const chatFromList = readFirstChatRecord(message);
|
||||
const chatGuid =
|
||||
readString(message, "chatGuid") ??
|
||||
readString(message, "chat_guid") ??
|
||||
readString(chat, "chatGuid") ??
|
||||
readString(chat, "chat_guid") ??
|
||||
readString(chat, "guid") ??
|
||||
readString(chatFromList, "chatGuid") ??
|
||||
readString(chatFromList, "chat_guid") ??
|
||||
readString(chatFromList, "guid");
|
||||
const chatIdentifier =
|
||||
readString(message, "chatIdentifier") ??
|
||||
readString(message, "chat_identifier") ??
|
||||
readString(chat, "chatIdentifier") ??
|
||||
readString(chat, "chat_identifier") ??
|
||||
readString(chat, "identifier") ??
|
||||
readString(chatFromList, "chatIdentifier") ??
|
||||
readString(chatFromList, "chat_identifier") ??
|
||||
readString(chatFromList, "identifier") ??
|
||||
extractChatIdentifierFromChatGuid(chatGuid);
|
||||
const chatId =
|
||||
readNumberLike(message, "chatId") ??
|
||||
readNumberLike(message, "chat_id") ??
|
||||
readNumberLike(chat, "chatId") ??
|
||||
readNumberLike(chat, "chat_id") ??
|
||||
readNumberLike(chat, "id") ??
|
||||
readNumberLike(chatFromList, "chatId") ??
|
||||
readNumberLike(chatFromList, "chat_id") ??
|
||||
readNumberLike(chatFromList, "id");
|
||||
const chatName =
|
||||
readString(message, "chatName") ??
|
||||
readString(chat, "displayName") ??
|
||||
readString(chat, "name") ??
|
||||
readString(chatFromList, "displayName") ??
|
||||
readString(chatFromList, "name") ??
|
||||
undefined;
|
||||
|
||||
const chatParticipants = chat ? chat["participants"] : undefined;
|
||||
const messageParticipants = message["participants"];
|
||||
const chatsParticipants = chatFromList ? chatFromList["participants"] : undefined;
|
||||
const participants = Array.isArray(chatParticipants)
|
||||
? chatParticipants
|
||||
: Array.isArray(messageParticipants)
|
||||
? messageParticipants
|
||||
: Array.isArray(chatsParticipants)
|
||||
? chatsParticipants
|
||||
: [];
|
||||
const participantsCount = participants.length;
|
||||
const groupFromChatGuid = resolveGroupFlagFromChatGuid(chatGuid);
|
||||
const explicitIsGroup =
|
||||
readBoolean(message, "isGroup") ??
|
||||
readBoolean(message, "is_group") ??
|
||||
readBoolean(chat, "isGroup") ??
|
||||
readBoolean(message, "group");
|
||||
const isGroup =
|
||||
typeof groupFromChatGuid === "boolean"
|
||||
? groupFromChatGuid
|
||||
: (explicitIsGroup ?? participantsCount > 2);
|
||||
|
||||
const fromMe = readBoolean(message, "isFromMe") ?? readBoolean(message, "is_from_me");
|
||||
const timestampRaw =
|
||||
readNumberLike(message, "date") ??
|
||||
readNumberLike(message, "dateCreated") ??
|
||||
readNumberLike(message, "timestamp");
|
||||
const timestamp =
|
||||
typeof timestampRaw === "number"
|
||||
? timestampRaw > 1_000_000_000_000
|
||||
? timestampRaw
|
||||
: timestampRaw * 1000
|
||||
: undefined;
|
||||
|
||||
const normalizedSender = normalizeBlueBubblesHandle(senderId);
|
||||
if (!normalizedSender) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
action,
|
||||
emoji,
|
||||
senderId: normalizedSender,
|
||||
senderName,
|
||||
messageId: associatedGuid,
|
||||
timestamp,
|
||||
isGroup,
|
||||
chatId,
|
||||
chatGuid,
|
||||
chatIdentifier,
|
||||
chatName,
|
||||
fromMe,
|
||||
};
|
||||
}
|
||||
@@ -1,997 +0,0 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
createReplyPrefixOptions,
|
||||
logAckFailure,
|
||||
logInboundDrop,
|
||||
logTypingFailure,
|
||||
resolveAckReaction,
|
||||
resolveControlCommandGate,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type {
|
||||
BlueBubblesCoreRuntime,
|
||||
BlueBubblesRuntimeEnv,
|
||||
WebhookTarget,
|
||||
} from "./monitor-shared.js";
|
||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
||||
import { sendBlueBubblesMedia } from "./media-send.js";
|
||||
import {
|
||||
buildMessagePlaceholder,
|
||||
formatGroupAllowlistEntry,
|
||||
formatGroupMembers,
|
||||
formatReplyTag,
|
||||
parseTapbackText,
|
||||
resolveGroupFlagFromChatGuid,
|
||||
resolveTapbackContext,
|
||||
type NormalizedWebhookMessage,
|
||||
type NormalizedWebhookReaction,
|
||||
} from "./monitor-normalize.js";
|
||||
import {
|
||||
getShortIdForUuid,
|
||||
rememberBlueBubblesReplyCache,
|
||||
resolveBlueBubblesMessageId,
|
||||
resolveReplyContextFromCache,
|
||||
} from "./monitor-reply-cache.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
|
||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
import { formatBlueBubblesChatTarget, isAllowedBlueBubblesSender } from "./targets.js";
|
||||
|
||||
const DEFAULT_TEXT_LIMIT = 4000;
|
||||
const invalidAckReactions = new Set<string>();
|
||||
const REPLY_DIRECTIVE_TAG_RE = /\[\[\s*(?:reply_to_current|reply_to\s*:\s*[^\]\n]+)\s*\]\]/gi;
|
||||
|
||||
export function logVerbose(
|
||||
core: BlueBubblesCoreRuntime,
|
||||
runtime: BlueBubblesRuntimeEnv,
|
||||
message: string,
|
||||
): void {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
runtime.log?.(`[bluebubbles] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function logGroupAllowlistHint(params: {
|
||||
runtime: BlueBubblesRuntimeEnv;
|
||||
reason: string;
|
||||
entry: string | null;
|
||||
chatName?: string;
|
||||
accountId?: string;
|
||||
}): void {
|
||||
const log = params.runtime.log ?? console.log;
|
||||
const nameHint = params.chatName ? ` (group name: ${params.chatName})` : "";
|
||||
const accountHint = params.accountId
|
||||
? ` (or channels.bluebubbles.accounts.${params.accountId}.groupAllowFrom)`
|
||||
: "";
|
||||
if (params.entry) {
|
||||
log(
|
||||
`[bluebubbles] group message blocked (${params.reason}). Allow this group by adding ` +
|
||||
`"${params.entry}" to channels.bluebubbles.groupAllowFrom${nameHint}.`,
|
||||
);
|
||||
log(
|
||||
`[bluebubbles] add to config: channels.bluebubbles.groupAllowFrom=["${params.entry}"]${accountHint}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
log(
|
||||
`[bluebubbles] group message blocked (${params.reason}). Allow groups by setting ` +
|
||||
`channels.bluebubbles.groupPolicy="open" or adding a group id to ` +
|
||||
`channels.bluebubbles.groupAllowFrom${accountHint}${nameHint}.`,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveBlueBubblesAckReaction(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
core: BlueBubblesCoreRuntime;
|
||||
runtime: BlueBubblesRuntimeEnv;
|
||||
}): string | null {
|
||||
const raw = resolveAckReaction(params.cfg, params.agentId).trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
normalizeBlueBubblesReactionInput(raw);
|
||||
return raw;
|
||||
} catch {
|
||||
const key = raw.toLowerCase();
|
||||
if (!invalidAckReactions.has(key)) {
|
||||
invalidAckReactions.add(key);
|
||||
logVerbose(
|
||||
params.core,
|
||||
params.runtime,
|
||||
`ack reaction skipped (unsupported for BlueBubbles): ${raw}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function processMessage(
|
||||
message: NormalizedWebhookMessage,
|
||||
target: WebhookTarget,
|
||||
): Promise<void> {
|
||||
const { account, config, runtime, core, statusSink } = target;
|
||||
const privateApiEnabled = getCachedBlueBubblesPrivateApiStatus(account.accountId) !== false;
|
||||
|
||||
const groupFlag = resolveGroupFlagFromChatGuid(message.chatGuid);
|
||||
const isGroup = typeof groupFlag === "boolean" ? groupFlag : message.isGroup;
|
||||
|
||||
const text = message.text.trim();
|
||||
const attachments = message.attachments ?? [];
|
||||
const placeholder = buildMessagePlaceholder(message);
|
||||
// Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format
|
||||
// For tapbacks, we'll append [[reply_to:N]] at the end; for regular messages, prepend it
|
||||
const tapbackContext = resolveTapbackContext(message);
|
||||
const tapbackParsed = parseTapbackText({
|
||||
text,
|
||||
emojiHint: tapbackContext?.emojiHint,
|
||||
actionHint: tapbackContext?.actionHint,
|
||||
requireQuoted: !tapbackContext,
|
||||
});
|
||||
const isTapbackMessage = Boolean(tapbackParsed);
|
||||
const rawBody = tapbackParsed
|
||||
? tapbackParsed.action === "removed"
|
||||
? `removed ${tapbackParsed.emoji} reaction`
|
||||
: `reacted with ${tapbackParsed.emoji}`
|
||||
: text || placeholder;
|
||||
|
||||
const cacheMessageId = message.messageId?.trim();
|
||||
let messageShortId: string | undefined;
|
||||
const cacheInboundMessage = () => {
|
||||
if (!cacheMessageId) {
|
||||
return;
|
||||
}
|
||||
const cacheEntry = rememberBlueBubblesReplyCache({
|
||||
accountId: account.accountId,
|
||||
messageId: cacheMessageId,
|
||||
chatGuid: message.chatGuid,
|
||||
chatIdentifier: message.chatIdentifier,
|
||||
chatId: message.chatId,
|
||||
senderLabel: message.fromMe ? "me" : message.senderId,
|
||||
body: rawBody,
|
||||
timestamp: message.timestamp ?? Date.now(),
|
||||
});
|
||||
messageShortId = cacheEntry.shortId;
|
||||
};
|
||||
|
||||
if (message.fromMe) {
|
||||
// Cache from-me messages so reply context can resolve sender/body.
|
||||
cacheInboundMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rawBody) {
|
||||
logVerbose(core, runtime, `drop: empty text sender=${message.senderId}`);
|
||||
return;
|
||||
}
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`msg sender=${message.senderId} group=${isGroup} textLen=${text.length} attachments=${attachments.length} chatGuid=${message.chatGuid ?? ""} chatId=${message.chatId ?? ""}`,
|
||||
);
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
||||
const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
|
||||
const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
|
||||
const storeAllowFrom = await core.channel.pairing
|
||||
.readAllowFromStore("bluebubbles")
|
||||
.catch(() => []);
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean);
|
||||
const effectiveGroupAllowFrom = [
|
||||
...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
|
||||
...storeAllowFrom,
|
||||
]
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean);
|
||||
const groupAllowEntry = formatGroupAllowlistEntry({
|
||||
chatGuid: message.chatGuid,
|
||||
chatId: message.chatId ?? undefined,
|
||||
chatIdentifier: message.chatIdentifier ?? undefined,
|
||||
});
|
||||
const groupName = message.chatName?.trim() || undefined;
|
||||
|
||||
if (isGroup) {
|
||||
if (groupPolicy === "disabled") {
|
||||
logVerbose(core, runtime, "Blocked BlueBubbles group message (groupPolicy=disabled)");
|
||||
logGroupAllowlistHint({
|
||||
runtime,
|
||||
reason: "groupPolicy=disabled",
|
||||
entry: groupAllowEntry,
|
||||
chatName: groupName,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (groupPolicy === "allowlist") {
|
||||
if (effectiveGroupAllowFrom.length === 0) {
|
||||
logVerbose(core, runtime, "Blocked BlueBubbles group message (no allowlist)");
|
||||
logGroupAllowlistHint({
|
||||
runtime,
|
||||
reason: "groupPolicy=allowlist (empty allowlist)",
|
||||
entry: groupAllowEntry,
|
||||
chatName: groupName,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
sender: message.senderId,
|
||||
chatId: message.chatId ?? undefined,
|
||||
chatGuid: message.chatGuid ?? undefined,
|
||||
chatIdentifier: message.chatIdentifier ?? undefined,
|
||||
});
|
||||
if (!allowed) {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`Blocked BlueBubbles sender ${message.senderId} (not in groupAllowFrom)`,
|
||||
);
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`drop: group sender not allowed sender=${message.senderId} allowFrom=${effectiveGroupAllowFrom.join(",")}`,
|
||||
);
|
||||
logGroupAllowlistHint({
|
||||
runtime,
|
||||
reason: "groupPolicy=allowlist (not allowlisted)",
|
||||
entry: groupAllowEntry,
|
||||
chatName: groupName,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (dmPolicy === "disabled") {
|
||||
logVerbose(core, runtime, `Blocked BlueBubbles DM from ${message.senderId}`);
|
||||
logVerbose(core, runtime, `drop: dmPolicy disabled sender=${message.senderId}`);
|
||||
return;
|
||||
}
|
||||
if (dmPolicy !== "open") {
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
allowFrom: effectiveAllowFrom,
|
||||
sender: message.senderId,
|
||||
chatId: message.chatId ?? undefined,
|
||||
chatGuid: message.chatGuid ?? undefined,
|
||||
chatIdentifier: message.chatIdentifier ?? undefined,
|
||||
});
|
||||
if (!allowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: "bluebubbles",
|
||||
id: message.senderId,
|
||||
meta: { name: message.senderName },
|
||||
});
|
||||
runtime.log?.(
|
||||
`[bluebubbles] pairing request sender=${message.senderId} created=${created}`,
|
||||
);
|
||||
if (created) {
|
||||
logVerbose(core, runtime, `bluebubbles pairing request sender=${message.senderId}`);
|
||||
try {
|
||||
await sendMessageBlueBubbles(
|
||||
message.senderId,
|
||||
core.channel.pairing.buildPairingReply({
|
||||
channel: "bluebubbles",
|
||||
idLine: `Your BlueBubbles sender id: ${message.senderId}`,
|
||||
code,
|
||||
}),
|
||||
{ cfg: config, accountId: account.accountId },
|
||||
);
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`bluebubbles pairing reply failed for ${message.senderId}: ${String(err)}`,
|
||||
);
|
||||
runtime.error?.(
|
||||
`[bluebubbles] pairing reply failed sender=${message.senderId}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`Blocked unauthorized BlueBubbles sender ${message.senderId} (dmPolicy=${dmPolicy})`,
|
||||
);
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`drop: dm sender not allowed sender=${message.senderId} allowFrom=${effectiveAllowFrom.join(",")}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chatId = message.chatId ?? undefined;
|
||||
const chatGuid = message.chatGuid ?? undefined;
|
||||
const chatIdentifier = message.chatIdentifier ?? undefined;
|
||||
const peerId = isGroup
|
||||
? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group"))
|
||||
: message.senderId;
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg: config,
|
||||
channel: "bluebubbles",
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
},
|
||||
});
|
||||
|
||||
// Mention gating for group chats (parity with iMessage/WhatsApp)
|
||||
const messageText = text;
|
||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config, route.agentId);
|
||||
const wasMentioned = isGroup
|
||||
? core.channel.mentions.matchesMentionPatterns(messageText, mentionRegexes)
|
||||
: true;
|
||||
const canDetectMention = mentionRegexes.length > 0;
|
||||
const requireMention = core.channel.groups.resolveRequireMention({
|
||||
cfg: config,
|
||||
channel: "bluebubbles",
|
||||
groupId: peerId,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
// Command gating (parity with iMessage/WhatsApp)
|
||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||
const hasControlCmd = core.channel.text.hasControlCommand(messageText, config);
|
||||
const ownerAllowedForCommands =
|
||||
effectiveAllowFrom.length > 0
|
||||
? isAllowedBlueBubblesSender({
|
||||
allowFrom: effectiveAllowFrom,
|
||||
sender: message.senderId,
|
||||
chatId: message.chatId ?? undefined,
|
||||
chatGuid: message.chatGuid ?? undefined,
|
||||
chatIdentifier: message.chatIdentifier ?? undefined,
|
||||
})
|
||||
: false;
|
||||
const groupAllowedForCommands =
|
||||
effectiveGroupAllowFrom.length > 0
|
||||
? isAllowedBlueBubblesSender({
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
sender: message.senderId,
|
||||
chatId: message.chatId ?? undefined,
|
||||
chatGuid: message.chatGuid ?? undefined,
|
||||
chatIdentifier: message.chatIdentifier ?? undefined,
|
||||
})
|
||||
: false;
|
||||
const dmAuthorized = dmPolicy === "open" || ownerAllowedForCommands;
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: effectiveAllowFrom.length > 0, allowed: ownerAllowedForCommands },
|
||||
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
|
||||
],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: hasControlCmd,
|
||||
});
|
||||
const commandAuthorized = isGroup ? commandGate.commandAuthorized : dmAuthorized;
|
||||
|
||||
// Block control commands from unauthorized senders in groups
|
||||
if (isGroup && commandGate.shouldBlock) {
|
||||
logInboundDrop({
|
||||
log: (msg) => logVerbose(core, runtime, msg),
|
||||
channel: "bluebubbles",
|
||||
reason: "control command (unauthorized)",
|
||||
target: message.senderId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow control commands to bypass mention gating when authorized (parity with iMessage)
|
||||
const shouldBypassMention =
|
||||
isGroup && requireMention && !wasMentioned && commandAuthorized && hasControlCmd;
|
||||
const effectiveWasMentioned = wasMentioned || shouldBypassMention;
|
||||
|
||||
// Skip group messages that require mention but weren't mentioned
|
||||
if (isGroup && requireMention && canDetectMention && !wasMentioned && !shouldBypassMention) {
|
||||
logVerbose(core, runtime, `bluebubbles: skipping group message (no mention)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache allowed inbound messages so later replies can resolve sender/body without
|
||||
// surfacing dropped content (allowlist/mention/command gating).
|
||||
cacheInboundMessage();
|
||||
|
||||
const baseUrl = account.config.serverUrl?.trim();
|
||||
const password = account.config.password?.trim();
|
||||
const maxBytes =
|
||||
account.config.mediaMaxMb && account.config.mediaMaxMb > 0
|
||||
? account.config.mediaMaxMb * 1024 * 1024
|
||||
: 8 * 1024 * 1024;
|
||||
|
||||
let mediaUrls: string[] = [];
|
||||
let mediaPaths: string[] = [];
|
||||
let mediaTypes: string[] = [];
|
||||
if (attachments.length > 0) {
|
||||
if (!baseUrl || !password) {
|
||||
logVerbose(core, runtime, "attachment download skipped (missing serverUrl/password)");
|
||||
} else {
|
||||
for (const attachment of attachments) {
|
||||
if (!attachment.guid) {
|
||||
continue;
|
||||
}
|
||||
if (attachment.totalBytes && attachment.totalBytes > maxBytes) {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`attachment too large guid=${attachment.guid} bytes=${attachment.totalBytes}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const downloaded = await downloadBlueBubblesAttachment(attachment, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
maxBytes,
|
||||
});
|
||||
const saved = await core.channel.media.saveMediaBuffer(
|
||||
Buffer.from(downloaded.buffer),
|
||||
downloaded.contentType,
|
||||
"inbound",
|
||||
maxBytes,
|
||||
);
|
||||
mediaPaths.push(saved.path);
|
||||
mediaUrls.push(saved.path);
|
||||
if (saved.contentType) {
|
||||
mediaTypes.push(saved.contentType);
|
||||
}
|
||||
} catch (err) {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`attachment download failed guid=${attachment.guid} err=${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let replyToId = message.replyToId;
|
||||
let replyToBody = message.replyToBody;
|
||||
let replyToSender = message.replyToSender;
|
||||
let replyToShortId: string | undefined;
|
||||
|
||||
if (isTapbackMessage && tapbackContext?.replyToId) {
|
||||
replyToId = tapbackContext.replyToId;
|
||||
}
|
||||
|
||||
if (replyToId) {
|
||||
const cached = resolveReplyContextFromCache({
|
||||
accountId: account.accountId,
|
||||
replyToId,
|
||||
chatGuid: message.chatGuid,
|
||||
chatIdentifier: message.chatIdentifier,
|
||||
chatId: message.chatId,
|
||||
});
|
||||
if (cached) {
|
||||
if (!replyToBody && cached.body) {
|
||||
replyToBody = cached.body;
|
||||
}
|
||||
if (!replyToSender && cached.senderLabel) {
|
||||
replyToSender = cached.senderLabel;
|
||||
}
|
||||
replyToShortId = cached.shortId;
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
const preview = (cached.body ?? "").replace(/\s+/g, " ").slice(0, 120);
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`reply-context cache hit replyToId=${replyToId} sender=${replyToSender ?? ""} body="${preview}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no cached short ID, try to get one from the UUID directly
|
||||
if (replyToId && !replyToShortId) {
|
||||
replyToShortId = getShortIdForUuid(replyToId);
|
||||
}
|
||||
|
||||
// Use inline [[reply_to:N]] tag format
|
||||
// For tapbacks/reactions: append at end (e.g., "reacted with ❤️ [[reply_to:4]]")
|
||||
// For regular replies: prepend at start (e.g., "[[reply_to:4]] Awesome")
|
||||
const replyTag = formatReplyTag({ replyToId, replyToShortId });
|
||||
const baseBody = replyTag
|
||||
? isTapbackMessage
|
||||
? `${rawBody} ${replyTag}`
|
||||
: `${replyTag} ${rawBody}`
|
||||
: rawBody;
|
||||
const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
|
||||
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
|
||||
const groupMembers = isGroup
|
||||
? formatGroupMembers({
|
||||
participants: message.participants,
|
||||
fallback: message.senderId ? { id: message.senderId, name: message.senderName } : undefined,
|
||||
})
|
||||
: undefined;
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "BlueBubbles",
|
||||
from: fromLabel,
|
||||
timestamp: message.timestamp,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: baseBody,
|
||||
});
|
||||
let chatGuidForActions = chatGuid;
|
||||
if (!chatGuidForActions && baseUrl && password) {
|
||||
const resolveTarget =
|
||||
isGroup && (chatId || chatIdentifier)
|
||||
? chatId
|
||||
? ({ kind: "chat_id", chatId } as const)
|
||||
: ({ kind: "chat_identifier", chatIdentifier: chatIdentifier ?? "" } as const)
|
||||
: ({ kind: "handle", address: message.senderId } as const);
|
||||
if (resolveTarget.kind !== "chat_identifier" || resolveTarget.chatIdentifier) {
|
||||
chatGuidForActions =
|
||||
(await resolveChatGuidForTarget({
|
||||
baseUrl,
|
||||
password,
|
||||
target: resolveTarget,
|
||||
})) ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const ackReactionScope = config.messages?.ackReactionScope ?? "group-mentions";
|
||||
const removeAckAfterReply = config.messages?.removeAckAfterReply ?? false;
|
||||
const ackReactionValue = resolveBlueBubblesAckReaction({
|
||||
cfg: config,
|
||||
agentId: route.agentId,
|
||||
core,
|
||||
runtime,
|
||||
});
|
||||
const shouldAckReaction = () =>
|
||||
Boolean(
|
||||
ackReactionValue &&
|
||||
core.channel.reactions.shouldAckReaction({
|
||||
scope: ackReactionScope,
|
||||
isDirect: !isGroup,
|
||||
isGroup,
|
||||
isMentionableGroup: isGroup,
|
||||
requireMention: Boolean(requireMention),
|
||||
canDetectMention,
|
||||
effectiveWasMentioned,
|
||||
shouldBypassMention,
|
||||
}),
|
||||
);
|
||||
const ackMessageId = message.messageId?.trim() || "";
|
||||
const ackReactionPromise =
|
||||
shouldAckReaction() && ackMessageId && chatGuidForActions && ackReactionValue
|
||||
? sendBlueBubblesReaction({
|
||||
chatGuid: chatGuidForActions,
|
||||
messageGuid: ackMessageId,
|
||||
emoji: ackReactionValue,
|
||||
opts: { cfg: config, accountId: account.accountId },
|
||||
}).then(
|
||||
() => true,
|
||||
(err) => {
|
||||
logVerbose(
|
||||
core,
|
||||
runtime,
|
||||
`ack reaction failed chatGuid=${chatGuidForActions} msg=${ackMessageId}: ${String(err)}`,
|
||||
);
|
||||
return false;
|
||||
},
|
||||
)
|
||||
: null;
|
||||
|
||||
// Respect sendReadReceipts config (parity with WhatsApp)
|
||||
const sendReadReceipts = account.config.sendReadReceipts !== false;
|
||||
if (chatGuidForActions && baseUrl && password && sendReadReceipts) {
|
||||
try {
|
||||
await markBlueBubblesChatRead(chatGuidForActions, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
logVerbose(core, runtime, `marked read chatGuid=${chatGuidForActions}`);
|
||||
} catch (err) {
|
||||
runtime.error?.(`[bluebubbles] mark read failed: ${String(err)}`);
|
||||
}
|
||||
} else if (!sendReadReceipts) {
|
||||
logVerbose(core, runtime, "mark read skipped (sendReadReceipts=false)");
|
||||
} else {
|
||||
logVerbose(core, runtime, "mark read skipped (missing chatGuid or credentials)");
|
||||
}
|
||||
|
||||
const outboundTarget = isGroup
|
||||
? formatBlueBubblesChatTarget({
|
||||
chatId,
|
||||
chatGuid: chatGuidForActions ?? chatGuid,
|
||||
chatIdentifier,
|
||||
}) || peerId
|
||||
: chatGuidForActions
|
||||
? formatBlueBubblesChatTarget({ chatGuid: chatGuidForActions })
|
||||
: message.senderId;
|
||||
|
||||
const maybeEnqueueOutboundMessageId = (messageId?: string, snippet?: string) => {
|
||||
const trimmed = messageId?.trim();
|
||||
if (!trimmed || trimmed === "ok" || trimmed === "unknown") {
|
||||
return;
|
||||
}
|
||||
// Cache outbound message to get short ID
|
||||
const cacheEntry = rememberBlueBubblesReplyCache({
|
||||
accountId: account.accountId,
|
||||
messageId: trimmed,
|
||||
chatGuid: chatGuidForActions ?? chatGuid,
|
||||
chatIdentifier,
|
||||
chatId,
|
||||
senderLabel: "me",
|
||||
body: snippet ?? "",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
const displayId = cacheEntry.shortId || trimmed;
|
||||
const preview = snippet ? ` "${snippet.slice(0, 12)}${snippet.length > 12 ? "…" : ""}"` : "";
|
||||
core.system.enqueueSystemEvent(`Assistant sent${preview} [message_id:${displayId}]`, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `bluebubbles:outbound:${outboundTarget}:${trimmed}`,
|
||||
});
|
||||
};
|
||||
const sanitizeReplyDirectiveText = (value: string): string => {
|
||||
if (privateApiEnabled) {
|
||||
return value;
|
||||
}
|
||||
return value
|
||||
.replace(REPLY_DIRECTIVE_TAG_RE, " ")
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.trim();
|
||||
};
|
||||
|
||||
const ctxPayload = {
|
||||
Body: body,
|
||||
BodyForAgent: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
BodyForCommands: rawBody,
|
||||
MediaUrl: mediaUrls[0],
|
||||
MediaUrls: mediaUrls.length > 0 ? mediaUrls : undefined,
|
||||
MediaPath: mediaPaths[0],
|
||||
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
|
||||
MediaType: mediaTypes[0],
|
||||
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
|
||||
From: isGroup ? `group:${peerId}` : `bluebubbles:${message.senderId}`,
|
||||
To: `bluebubbles:${outboundTarget}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
// Use short ID for token savings (agent can use this to reference the message)
|
||||
ReplyToId: replyToShortId || replyToId,
|
||||
ReplyToIdFull: replyToId,
|
||||
ReplyToBody: replyToBody,
|
||||
ReplyToSender: replyToSender,
|
||||
GroupSubject: groupSubject,
|
||||
GroupMembers: groupMembers,
|
||||
SenderName: message.senderName || undefined,
|
||||
SenderId: message.senderId,
|
||||
Provider: "bluebubbles",
|
||||
Surface: "bluebubbles",
|
||||
// Use short ID for token savings (agent can use this to reference the message)
|
||||
MessageSid: messageShortId || message.messageId,
|
||||
MessageSidFull: message.messageId,
|
||||
Timestamp: message.timestamp,
|
||||
OriginatingChannel: "bluebubbles",
|
||||
OriginatingTo: `bluebubbles:${outboundTarget}`,
|
||||
WasMentioned: effectiveWasMentioned,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
};
|
||||
|
||||
let sentMessage = false;
|
||||
let streamingActive = false;
|
||||
let typingRestartTimer: NodeJS.Timeout | undefined;
|
||||
const typingRestartDelayMs = 150;
|
||||
const clearTypingRestartTimer = () => {
|
||||
if (typingRestartTimer) {
|
||||
clearTimeout(typingRestartTimer);
|
||||
typingRestartTimer = undefined;
|
||||
}
|
||||
};
|
||||
const restartTypingSoon = () => {
|
||||
if (!streamingActive || !chatGuidForActions || !baseUrl || !password) {
|
||||
return;
|
||||
}
|
||||
clearTypingRestartTimer();
|
||||
typingRestartTimer = setTimeout(() => {
|
||||
typingRestartTimer = undefined;
|
||||
if (!streamingActive) {
|
||||
return;
|
||||
}
|
||||
sendBlueBubblesTyping(chatGuidForActions, true, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
}).catch((err) => {
|
||||
runtime.error?.(`[bluebubbles] typing restart failed: ${String(err)}`);
|
||||
});
|
||||
}, typingRestartDelayMs);
|
||||
};
|
||||
try {
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg: config,
|
||||
agentId: route.agentId,
|
||||
channel: "bluebubbles",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
deliver: async (payload, info) => {
|
||||
const rawReplyToId =
|
||||
privateApiEnabled && typeof payload.replyToId === "string"
|
||||
? payload.replyToId.trim()
|
||||
: "";
|
||||
// Resolve short ID (e.g., "5") to full UUID
|
||||
const replyToMessageGuid = rawReplyToId
|
||||
? resolveBlueBubblesMessageId(rawReplyToId, { requireKnownShortId: true })
|
||||
: "";
|
||||
const mediaList = payload.mediaUrls?.length
|
||||
? payload.mediaUrls
|
||||
: payload.mediaUrl
|
||||
? [payload.mediaUrl]
|
||||
: [];
|
||||
if (mediaList.length > 0) {
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg: config,
|
||||
channel: "bluebubbles",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const text = sanitizeReplyDirectiveText(
|
||||
core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
|
||||
);
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? text : undefined;
|
||||
first = false;
|
||||
const result = await sendBlueBubblesMedia({
|
||||
cfg: config,
|
||||
to: outboundTarget,
|
||||
mediaUrl,
|
||||
caption: caption ?? undefined,
|
||||
replyToId: replyToMessageGuid || null,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const cachedBody = (caption ?? "").trim() || "<media:attachment>";
|
||||
maybeEnqueueOutboundMessageId(result.messageId, cachedBody);
|
||||
sentMessage = true;
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
if (info.kind === "block") {
|
||||
restartTypingSoon();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const textLimit =
|
||||
account.config.textChunkLimit && account.config.textChunkLimit > 0
|
||||
? account.config.textChunkLimit
|
||||
: DEFAULT_TEXT_LIMIT;
|
||||
const chunkMode = account.config.chunkMode ?? "length";
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg: config,
|
||||
channel: "bluebubbles",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const text = sanitizeReplyDirectiveText(
|
||||
core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
|
||||
);
|
||||
const chunks =
|
||||
chunkMode === "newline"
|
||||
? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode)
|
||||
: core.channel.text.chunkMarkdownText(text, textLimit);
|
||||
if (!chunks.length && text) {
|
||||
chunks.push(text);
|
||||
}
|
||||
if (!chunks.length) {
|
||||
return;
|
||||
}
|
||||
for (const chunk of chunks) {
|
||||
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
replyToMessageGuid: replyToMessageGuid || undefined,
|
||||
});
|
||||
maybeEnqueueOutboundMessageId(result.messageId, chunk);
|
||||
sentMessage = true;
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
if (info.kind === "block") {
|
||||
restartTypingSoon();
|
||||
}
|
||||
}
|
||||
},
|
||||
onReplyStart: async () => {
|
||||
if (!chatGuidForActions) {
|
||||
return;
|
||||
}
|
||||
if (!baseUrl || !password) {
|
||||
return;
|
||||
}
|
||||
streamingActive = true;
|
||||
clearTypingRestartTimer();
|
||||
try {
|
||||
await sendBlueBubblesTyping(chatGuidForActions, true, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error?.(`[bluebubbles] typing start failed: ${String(err)}`);
|
||||
}
|
||||
},
|
||||
onIdle: async () => {
|
||||
if (!chatGuidForActions) {
|
||||
return;
|
||||
}
|
||||
if (!baseUrl || !password) {
|
||||
return;
|
||||
}
|
||||
// Intentionally no-op for block streaming. We stop typing in finally
|
||||
// after the run completes to avoid flicker between paragraph blocks.
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
onModelSelected,
|
||||
disableBlockStreaming:
|
||||
typeof account.config.blockStreaming === "boolean"
|
||||
? !account.config.blockStreaming
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
const shouldStopTyping =
|
||||
Boolean(chatGuidForActions && baseUrl && password) && (streamingActive || !sentMessage);
|
||||
streamingActive = false;
|
||||
clearTypingRestartTimer();
|
||||
if (sentMessage && chatGuidForActions && ackMessageId) {
|
||||
core.channel.reactions.removeAckReactionAfterReply({
|
||||
removeAfterReply: removeAckAfterReply,
|
||||
ackReactionPromise,
|
||||
ackReactionValue: ackReactionValue ?? null,
|
||||
remove: () =>
|
||||
sendBlueBubblesReaction({
|
||||
chatGuid: chatGuidForActions,
|
||||
messageGuid: ackMessageId,
|
||||
emoji: ackReactionValue ?? "",
|
||||
remove: true,
|
||||
opts: { cfg: config, accountId: account.accountId },
|
||||
}),
|
||||
onError: (err) => {
|
||||
logAckFailure({
|
||||
log: (msg) => logVerbose(core, runtime, msg),
|
||||
channel: "bluebubbles",
|
||||
target: `${chatGuidForActions}/${ackMessageId}`,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
if (shouldStopTyping && chatGuidForActions) {
|
||||
// Stop typing after streaming completes to avoid a stuck indicator.
|
||||
sendBlueBubblesTyping(chatGuidForActions, false, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
}).catch((err) => {
|
||||
logTypingFailure({
|
||||
log: (msg) => logVerbose(core, runtime, msg),
|
||||
channel: "bluebubbles",
|
||||
action: "stop",
|
||||
target: chatGuidForActions,
|
||||
error: err,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function processReaction(
|
||||
reaction: NormalizedWebhookReaction,
|
||||
target: WebhookTarget,
|
||||
): Promise<void> {
|
||||
const { account, config, runtime, core } = target;
|
||||
if (reaction.fromMe) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
||||
const configAllowFrom = (account.config.allowFrom ?? []).map((entry) => String(entry));
|
||||
const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((entry) => String(entry));
|
||||
const storeAllowFrom = await core.channel.pairing
|
||||
.readAllowFromStore("bluebubbles")
|
||||
.catch(() => []);
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom]
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean);
|
||||
const effectiveGroupAllowFrom = [
|
||||
...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
|
||||
...storeAllowFrom,
|
||||
]
|
||||
.map((entry) => String(entry).trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (reaction.isGroup) {
|
||||
if (groupPolicy === "disabled") {
|
||||
return;
|
||||
}
|
||||
if (groupPolicy === "allowlist") {
|
||||
if (effectiveGroupAllowFrom.length === 0) {
|
||||
return;
|
||||
}
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
allowFrom: effectiveGroupAllowFrom,
|
||||
sender: reaction.senderId,
|
||||
chatId: reaction.chatId ?? undefined,
|
||||
chatGuid: reaction.chatGuid ?? undefined,
|
||||
chatIdentifier: reaction.chatIdentifier ?? undefined,
|
||||
});
|
||||
if (!allowed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (dmPolicy === "disabled") {
|
||||
return;
|
||||
}
|
||||
if (dmPolicy !== "open") {
|
||||
const allowed = isAllowedBlueBubblesSender({
|
||||
allowFrom: effectiveAllowFrom,
|
||||
sender: reaction.senderId,
|
||||
chatId: reaction.chatId ?? undefined,
|
||||
chatGuid: reaction.chatGuid ?? undefined,
|
||||
chatIdentifier: reaction.chatIdentifier ?? undefined,
|
||||
});
|
||||
if (!allowed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chatId = reaction.chatId ?? undefined;
|
||||
const chatGuid = reaction.chatGuid ?? undefined;
|
||||
const chatIdentifier = reaction.chatIdentifier ?? undefined;
|
||||
const peerId = reaction.isGroup
|
||||
? (chatGuid ?? chatIdentifier ?? (chatId ? String(chatId) : "group"))
|
||||
: reaction.senderId;
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg: config,
|
||||
channel: "bluebubbles",
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: reaction.isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
},
|
||||
});
|
||||
|
||||
const senderLabel = reaction.senderName || reaction.senderId;
|
||||
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
||||
// Use short ID for token savings
|
||||
const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
|
||||
// Format: "Tyler reacted with ❤️ [[reply_to:5]]" or "Tyler removed ❤️ reaction [[reply_to:5]]"
|
||||
const text =
|
||||
reaction.action === "removed"
|
||||
? `${senderLabel} removed ${reaction.emoji} reaction [[reply_to:${messageDisplayId}]]${chatLabel}`
|
||||
: `${senderLabel} reacted with ${reaction.emoji} [[reply_to:${messageDisplayId}]]${chatLabel}`;
|
||||
core.system.enqueueSystemEvent(text, {
|
||||
sessionKey: route.sessionKey,
|
||||
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
||||
});
|
||||
logVerbose(core, runtime, `reaction event enqueued: ${text}`);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user