mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-10 16:03:47 +08:00
Compare commits
120 Commits
codex/open
...
codex/ci-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da116019d3 | ||
|
|
fd472b0d57 | ||
|
|
4685fc7e77 | ||
|
|
52a0aa0672 | ||
|
|
a07dcfde84 | ||
|
|
7066316db8 | ||
|
|
ad24fccff5 | ||
|
|
c70ae1c96e | ||
|
|
67e61acac7 | ||
|
|
b70b7b0d94 | ||
|
|
3382ef2724 | ||
|
|
aa6b962a3a | ||
|
|
8ac3e41cdf | ||
|
|
574cc9de64 | ||
|
|
3cd4978fc2 | ||
|
|
2d492ab534 | ||
|
|
4becbc8b25 | ||
|
|
b4656f193a | ||
|
|
f537ea90ed | ||
|
|
037fa2f8fb | ||
|
|
94ec0d6aeb | ||
|
|
537115bbdc | ||
|
|
ec0e4ff218 | ||
|
|
b2f9ab9a1f | ||
|
|
041f0b87ec | ||
|
|
c96a12aeb9 | ||
|
|
f783101735 | ||
|
|
707eb8e1b3 | ||
|
|
e1854dfbf6 | ||
|
|
8727338372 | ||
|
|
2b210703a3 | ||
|
|
432e8943ad | ||
|
|
7a0dacbfba | ||
|
|
8790c54635 | ||
|
|
0f6dbb4390 | ||
|
|
ec59974a46 | ||
|
|
60f559e217 | ||
|
|
4c9f411f6d | ||
|
|
7ac312b8fe | ||
|
|
e7e4c68caf | ||
|
|
a2472dc31b | ||
|
|
177136c964 | ||
|
|
b2380b3ab1 | ||
|
|
89bc66feef | ||
|
|
36feecf018 | ||
|
|
1e98dbcad3 | ||
|
|
2909d8cd12 | ||
|
|
88da51d91b | ||
|
|
506861efd0 | ||
|
|
f7866c1c15 | ||
|
|
6c4eced494 | ||
|
|
eea84bc6ec | ||
|
|
d38561acbe | ||
|
|
d6346aaf63 | ||
|
|
e7814f7ba0 | ||
|
|
b67baae1f6 | ||
|
|
6db72746fb | ||
|
|
518d2dd6a9 | ||
|
|
14237aa6c0 | ||
|
|
8e6a4c2d82 | ||
|
|
b1ab7ba3ac | ||
|
|
4f210e98a5 | ||
|
|
7b344b8a8a | ||
|
|
d81772dbc7 | ||
|
|
2cc777539a | ||
|
|
1ad3893b39 | ||
|
|
17713ec988 | ||
|
|
1e4688a584 | ||
|
|
d6c05c1941 | ||
|
|
ab38f6471c | ||
|
|
f1b2c5639a | ||
|
|
3775651480 | ||
|
|
a5309b6f93 | ||
|
|
2b4c3c2057 | ||
|
|
daa042c9a0 | ||
|
|
d0d82ea67b | ||
|
|
8b7f40580d | ||
|
|
465bd0cef1 | ||
|
|
45f84cf639 | ||
|
|
c449a0a3c1 | ||
|
|
30ad059da8 | ||
|
|
85722d4cf2 | ||
|
|
865a90ccab | ||
|
|
57fa59ab92 | ||
|
|
6266b842d4 | ||
|
|
36c6d44eca | ||
|
|
91f404dc7e | ||
|
|
37d5cbe43a | ||
|
|
cf4d301a69 | ||
|
|
80441baa15 | ||
|
|
9854466a04 | ||
|
|
9dea537bae | ||
|
|
a622eecd3b | ||
|
|
29b165e456 | ||
|
|
5b31b3400e | ||
|
|
5024967e57 | ||
|
|
56b6585e2e | ||
|
|
d88c68fec1 | ||
|
|
d3731be2f0 | ||
|
|
5b3fce4c85 | ||
|
|
21544f9e53 | ||
|
|
825d82b5c9 | ||
|
|
99641f01a5 | ||
|
|
805aaa4ee8 | ||
|
|
5069c771e7 | ||
|
|
039ea5998e | ||
|
|
c2634b5e40 | ||
|
|
4229ffe2b9 | ||
|
|
80959219ce | ||
|
|
bfcfc17a8b | ||
|
|
c29ba9d21a | ||
|
|
8cac327c19 | ||
|
|
5c05347d11 | ||
|
|
ef7a5c3546 | ||
|
|
fb50c98d67 | ||
|
|
fd2b3ed6af | ||
|
|
ebfc5f8240 | ||
|
|
40f5305cd2 | ||
|
|
d6367c2c55 | ||
|
|
c0e482f4bd |
@@ -30,7 +30,9 @@ runs:
|
||||
|
||||
for deepen_by in 25 100 300; do
|
||||
echo "Base commit missing; deepening $FETCH_REF by $deepen_by."
|
||||
git fetch --no-tags --deepen="$deepen_by" origin "$FETCH_REF" || true
|
||||
if ! git fetch --no-tags --deepen="$deepen_by" origin "$FETCH_REF"; then
|
||||
echo "::warning title=ensure-base-commit fetch failed::Failed to deepen $FETCH_REF by $deepen_by while looking for $BASE_SHA"
|
||||
fi
|
||||
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
|
||||
echo "Resolved base commit after deepening: $BASE_SHA"
|
||||
exit 0
|
||||
@@ -38,7 +40,9 @@ runs:
|
||||
done
|
||||
|
||||
echo "Base commit still missing; fetching full history for $FETCH_REF."
|
||||
git fetch --no-tags origin "$FETCH_REF" || true
|
||||
if ! git fetch --no-tags origin "$FETCH_REF"; then
|
||||
echo "::warning title=ensure-base-commit fetch failed::Failed to fetch full history for $FETCH_REF while looking for $BASE_SHA"
|
||||
fi
|
||||
if git rev-parse --verify "$BASE_SHA^{commit}" >/dev/null 2>&1; then
|
||||
echo "Resolved base commit after full ref fetch: $BASE_SHA"
|
||||
exit 0
|
||||
|
||||
2
.github/actions/setup-node-env/action.yml
vendored
2
.github/actions/setup-node-env/action.yml
vendored
@@ -63,7 +63,7 @@ runs:
|
||||
|
||||
- name: Setup Bun
|
||||
if: inputs.install-bun == 'true'
|
||||
uses: oven-sh/setup-bun@v2.1.3
|
||||
uses: oven-sh/setup-bun@v2.2.0
|
||||
with:
|
||||
bun-version: "1.3.9"
|
||||
|
||||
|
||||
4
.github/workflows/auto-response.yml
vendored
4
.github/workflows/auto-response.yml
vendored
@@ -398,11 +398,13 @@ jobs:
|
||||
const invalidLabel = "invalid";
|
||||
const spamLabel = "r: spam";
|
||||
const dirtyLabel = "dirty";
|
||||
const badBarnacleLabel = "bad-barnacle";
|
||||
const noisyPrMessage =
|
||||
"Closing this PR because it looks dirty (too many unrelated or unexpected changes). This usually happens when a branch picks up unrelated commits or a merge went sideways. Please recreate the PR from a clean branch.";
|
||||
|
||||
if (pullRequest) {
|
||||
if (labelSet.has(dirtyLabel)) {
|
||||
// `bad-barnacle` exempts PRs that Barnacle incorrectly marked dirty.
|
||||
if (labelSet.has(dirtyLabel) && !labelSet.has(badBarnacleLabel)) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
|
||||
52
.github/workflows/ci.yml
vendored
52
.github/workflows/ci.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -16,6 +17,7 @@ jobs:
|
||||
# Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android).
|
||||
# Lint and format always run. Fail-safe: if detection fails, run everything.
|
||||
docs-scope:
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
outputs:
|
||||
docs_only: ${{ steps.check.outputs.docs_only }}
|
||||
@@ -183,8 +185,8 @@ jobs:
|
||||
run: pnpm release:check
|
||||
|
||||
checks:
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
needs: [docs-scope, changed-scope, build-artifacts]
|
||||
if: always() && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -221,7 +223,9 @@ jobs:
|
||||
cache_key_suffix: "node22"
|
||||
command: |
|
||||
pnpm build
|
||||
pnpm test
|
||||
node openclaw.mjs --help
|
||||
node openclaw.mjs status --json --timeout 1
|
||||
pnpm test:build:singleton
|
||||
node scripts/stage-bundled-plugin-runtime-deps.mjs
|
||||
node --import tsx scripts/release-check.ts
|
||||
steps:
|
||||
@@ -259,6 +263,17 @@ jobs:
|
||||
echo "OPENCLAW_TEST_SHARD_INDEX=$SHARD_INDEX" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Download dist artifact
|
||||
if: github.event_name == 'push' && matrix.task == 'test'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
|
||||
- name: Build dist
|
||||
if: github.event_name != 'push' && matrix.task == 'test' && matrix.runtime == 'node'
|
||||
run: pnpm build:plugin-sdk:test-dist
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
if: github.event_name != 'pull_request' || (matrix.runtime != 'bun' && matrix.task != 'compat-node22')
|
||||
run: ${{ matrix.command }}
|
||||
@@ -404,8 +419,8 @@ jobs:
|
||||
|
||||
build-smoke:
|
||||
name: "build-smoke"
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
needs: [docs-scope, changed-scope, build-artifacts]
|
||||
if: always() && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -419,7 +434,15 @@ jobs:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Download dist artifact
|
||||
if: github.event_name == 'push'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
|
||||
- name: Build dist
|
||||
if: github.event_name != 'push'
|
||||
run: pnpm build
|
||||
|
||||
- name: Smoke test CLI launcher help
|
||||
@@ -481,6 +504,7 @@ jobs:
|
||||
run: python -m pytest -q skills
|
||||
|
||||
secrets:
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -557,8 +581,8 @@ jobs:
|
||||
run: pre-commit run --all-files pnpm-audit-prod
|
||||
|
||||
checks-windows:
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_windows == 'true'
|
||||
needs: [docs-scope, changed-scope, build-artifacts]
|
||||
if: always() && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_windows == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success')
|
||||
runs-on: blacksmith-32vcpu-windows-2025
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
@@ -678,6 +702,17 @@ jobs:
|
||||
if: matrix.task == 'test'
|
||||
run: pnpm canvas:a2ui:bundle
|
||||
|
||||
- name: Download dist artifact
|
||||
if: github.event_name == 'push' && matrix.task == 'test'
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: dist-build
|
||||
path: dist/
|
||||
|
||||
- name: Build dist (Windows)
|
||||
if: github.event_name != 'push' && matrix.task == 'test'
|
||||
run: pnpm build:plugin-sdk:test-dist
|
||||
|
||||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
@@ -700,6 +735,9 @@ jobs:
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Build dist (macOS)
|
||||
run: pnpm build:plugin-sdk:test-dist
|
||||
|
||||
# --- Run all checks sequentially (fast gates first) ---
|
||||
- name: TS tests (macOS)
|
||||
env:
|
||||
|
||||
8
.github/workflows/docker-release.yml
vendored
8
.github/workflows/docker-release.yml
vendored
@@ -159,6 +159,8 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=docker-release-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-amd64
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
@@ -171,6 +173,8 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=docker-release-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-amd64
|
||||
build-args: |
|
||||
OPENCLAW_VARIANT=slim
|
||||
tags: ${{ steps.tags.outputs.slim }}
|
||||
@@ -272,6 +276,8 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
cache-from: type=gha,scope=docker-release-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-arm64
|
||||
tags: ${{ steps.tags.outputs.value }}
|
||||
labels: ${{ steps.labels.outputs.value }}
|
||||
provenance: false
|
||||
@@ -284,6 +290,8 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
cache-from: type=gha,scope=docker-release-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-release-arm64
|
||||
build-args: |
|
||||
OPENCLAW_VARIANT=slim
|
||||
tags: ${{ steps.tags.outputs.slim }}
|
||||
|
||||
4
.github/workflows/install-smoke.yml
vendored
4
.github/workflows/install-smoke.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -15,6 +16,7 @@ env:
|
||||
|
||||
jobs:
|
||||
docs-scope:
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
outputs:
|
||||
docs_only: ${{ steps.check.outputs.docs_only }}
|
||||
@@ -37,7 +39,7 @@ jobs:
|
||||
|
||||
install-smoke:
|
||||
needs: [docs-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true'
|
||||
if: (github.event_name != 'pull_request' || !github.event.pull_request.draft) && needs.docs-scope.outputs.docs_only != 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout CLI
|
||||
|
||||
2
.github/workflows/sandbox-common-smoke.yml
vendored
2
.github/workflows/sandbox-common-smoke.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
- Dockerfile.sandbox-common
|
||||
- scripts/sandbox-common-setup.sh
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
||||
paths:
|
||||
- Dockerfile.sandbox
|
||||
- Dockerfile.sandbox-common
|
||||
@@ -22,6 +23,7 @@ env:
|
||||
|
||||
jobs:
|
||||
sandbox-common-smoke:
|
||||
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
|
||||
exempt-pr-labels: maintainer,no-stale
|
||||
exempt-pr-labels: maintainer,no-stale,bad-barnacle
|
||||
operations-per-run: 2000
|
||||
ascending: true
|
||||
exempt-all-assignees: true
|
||||
@@ -98,7 +98,7 @@ jobs:
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
|
||||
exempt-pr-labels: maintainer,no-stale
|
||||
exempt-pr-labels: maintainer,no-stale,bad-barnacle
|
||||
operations-per-run: 2000
|
||||
ascending: true
|
||||
exempt-all-assignees: true
|
||||
|
||||
32
CHANGELOG.md
32
CHANGELOG.md
@@ -61,9 +61,16 @@ Docs: https://docs.openclaw.ai
|
||||
- Models/GitHub Copilot: allow forward-compat dynamic model ids without code updates, while preserving configured provider and per-model overrides for those synthetic models. (#51325) Thanks @fuller-stack-dev.
|
||||
- Agents/compaction: notify users when followup auto-compaction starts and finishes, keeping those notices out of TTS and preserving reply threading for the real assistant reply. (#38805) Thanks @zidongdesign.
|
||||
- Models/OpenAI: switch the default OpenAI setup model to `openai/gpt-5.4`, keep Codex on `openai-codex/gpt-5.4`, and centralize OpenAI chat, image, TTS, transcription, and embedding defaults in one shared module so future default-model updates stay low-churn. Thanks @vincentkoc.
|
||||
- Memory/plugins: let the active memory plugin register its own system-prompt section while preserving cache-clear and snapshot-load prompt isolation. (#40126) Thanks @jarimustonen.
|
||||
- Control UI/usage: improve usage overview styling, localization, and responsive chat/context-notice presentation, including safer theme color handling and unclipped usage-header menus. (#51951) Thanks @BunsDev.
|
||||
- Agents: add per-agent thinking/reasoning/fast defaults and auto-revert disallowed model overrides to the agent's default selection. Thanks @xuanmingguo and @vincentkoc.
|
||||
- Control UI/usage: drop the empty session-detail placeholder card so the usage view stays single-column until a real session detail panel is selected. (#52013) Thanks @BunsDev.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents/default timeout: raise the shared default agent timeout from `600s` to `48h` so long-running ACP and agent sessions do not fail unless you configure a shorter limit.
|
||||
- Gateway/Linux: auto-detect nvm-managed Node TLS CA bundle needs before CLI startup and refresh installed services that are missing `NODE_EXTRA_CA_CERTS`. (#51146) Thanks @GodsBoy.
|
||||
- Android/pairing: resolve portless secure setup URLs to `443` while preserving direct cleartext gateway defaults and explicit `:80` manual endpoints in onboarding. (#43540) Thanks @fmercurio.
|
||||
- CLI/config: make `config set --strict-json` enforce real JSON, prefer `JSON.parse` with JSON5 fallback for machine-written cron/subagent stores, and relabel raw config surfaces as `JSON/JSON5` to match actual compatibility. Related: #48415, #43127, #14529, #21332. Thanks @adhitShet and @vincentkoc.
|
||||
- CLI/Ollama onboarding: keep the interactive model picker for explicit `openclaw onboard --auth-choice ollama` runs so setup still selects a default model without reintroducing pre-picker auto-pulls. (#49249) Thanks @BruceMacD.
|
||||
- Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev.
|
||||
@@ -84,6 +91,10 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI/configure: clarify fresh-setup memory-search warnings so they say semantic recall needs at least one embedding provider, and scope the initial model allowlist picker to the provider selected in configure. Thanks @vincentkoc.
|
||||
- CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc.
|
||||
- CLI: avoid loading provider discovery during startup model normalization. (#46522) Thanks @ItsAditya-xyz and @vincentkoc.
|
||||
- Agents/Telegram: avoid rebuilding the full model catalog on ordinary inbound replies so Telegram message handling no longer pays multi-second core startup latency before reply generation. Thanks @vincentkoc.
|
||||
- Agents/inbound: lazy-load media and link understanding for plain-text turns and cache synced auth stores by auth-file state so ordinary inbound replies avoid unnecessary startup churn. Thanks @vincentkoc.
|
||||
- Telegram/polling: hard-timeout stuck `getUpdates` requests so wedged network paths fail over sooner instead of waiting for the polling stall watchdog. Thanks @vincentkoc.
|
||||
- Agents/models: cache `models.json` readiness by config and auth-file state so embedded runner turns stop paying repeated model-catalog startup work before replies. Thanks @vincentkoc.
|
||||
- Security/device pairing: harden `device.token.rotate` deny handling by keeping public failures generic while logging internal deny reasons and preserving approved-baseline enforcement. (`GHSA-7jrw-x62h-64p8`)
|
||||
- Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. (#46787) Thanks @zpbrent, @ijxpwastaken and @vincentkoc.
|
||||
- Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. (#46802) Thanks @vincentkoc.
|
||||
@@ -100,6 +111,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava.
|
||||
- Telegram/setup: warn when setup leaves DMs on pairing without an allowlist, and show valid account-scoped remediation commands. (#50710) Thanks @ernestodeoliveira.
|
||||
- Doctor/Telegram: replace the fresh-install empty group-allowlist false positive with first-run guidance that explains DM pairing approval and the next group setup steps, so new Telegram installs get actionable setup help instead of a broken-config warning. Thanks @vincentkoc.
|
||||
- Doctor/extensions: keep Matrix DM `allowFrom` repairs on the canonical `dm.allowFrom` path and stop treating Zalouser group sender gating as if it fell back to `allowFrom`, so doctor warnings and `--fix` stay aligned with runtime access control. Thanks @vincentkoc.
|
||||
- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao.
|
||||
- Channels/plugins: keep shared interactive payloads merge-ready by fixing Slack custom callback routing and repeat-click dedupe, allowing interactive-only sends, and preserving ordered Discord shared text blocks. (#47715) Thanks @vincentkoc.
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
|
||||
@@ -110,8 +122,11 @@ Docs: https://docs.openclaw.ai
|
||||
- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) Thanks @MonkeyLeeT.
|
||||
- WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason.
|
||||
- Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent.
|
||||
- Android/canvas: serialize A2UI action-status event strings before evaluating WebView JS, so action ids and multiline errors do not break the callback dispatch. (#43784) Thanks @Kaneki-x.
|
||||
- Android/camera: recycle intermediate and final snap bitmaps in `camera.snap` so repeated captures do not leak native image memory. (#41902) Thanks @Kaneki-x.
|
||||
- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274) Thanks @obviyus.
|
||||
- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969) Thanks @obviyus.
|
||||
- CI/onboarding smoke: surface `ensure-base-commit` fetch failures as workflow warnings and fail the onboarding Docker smoke when expected setup prompts drift instead of continuing silently. Thanks @Takhoffman.
|
||||
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
|
||||
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#46663) Fixes #40146. Thanks @Takhoffman.
|
||||
- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898.
|
||||
@@ -131,6 +146,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#46596) Fixes #45777. Thanks @odysseus0.
|
||||
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
|
||||
- Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark.
|
||||
- Plugins/runtime barrels: route bundled extension runtime imports through public `openclaw/plugin-sdk/*` subpaths and block relative cross-package escapes so packaged extensions stop depending on monorepo-only relative paths. (#51939) Thanks @vincentkoc.
|
||||
- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman.
|
||||
- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc.
|
||||
- Tlon/DM auth: defer cited-message expansion until after DM authorization and owner command handling, so unauthorized DMs and owner approval/admin commands no longer trigger cross-channel cite fetches before the deny or command path.
|
||||
@@ -170,6 +186,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Exec: harden host env override handling across gateway and node (#51207) Thanks @gladiator9797 and @joshavant.
|
||||
- Voice Call: enforce spoken-output contract and fix stream TTS silence regression (#51500) Thanks @joshavant.
|
||||
- xAI/models: rename the bundled Grok 4.20 catalog entries to the GA IDs and normalize saved deprecated beta IDs at runtime so existing configs and sessions keep resolving. (#50772) thanks @Jaaneek
|
||||
- Plugins/Matrix TTS: send auto-TTS replies as native Matrix voice bubbles instead of generic audio attachments. (#37080) thanks @Matthew19990919.
|
||||
|
||||
### Fixes
|
||||
|
||||
@@ -187,6 +204,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/security: add regression coverage proving pinned fallback host overrides stay bound to Telegram and delegate non-matching hostnames back to the original lookup path. Thanks @vincentkoc.
|
||||
- Secrets/exec refs: require explicit `--allow-exec` for `secrets apply` write plans that contain exec SecretRefs/providers, and align audit/configure/apply dry-run behavior to skip exec checks unless opted in to prevent unexpected command side effects. (#49417) Thanks @restriction and @joshavant.
|
||||
- Tools/image generation: add bundled fal image generation support so `image_generate` can target `fal/*` models with `FAL_KEY`, including single-image edit flows via FLUX image-to-image. Thanks @vincentkoc.
|
||||
- Messages/polls: treat zero-valued poll params on `message.send` as unset defaults while keeping non-zero poll params on the poll validation path. (#52150) Fixes #52118. Thanks @Bartok9.
|
||||
- xAI/web search: add missing Grok credential metadata so the bundled provider registration type-checks again. (#49472) thanks @scoootscooob.
|
||||
- Signal/runtime API: re-export `SignalAccountConfig` so Signal account resolution type-checks again. (#49470) Thanks @scoootscooob.
|
||||
- Google Chat/runtime API: thin the private runtime barrel onto the curated public SDK surface while keeping public Google Chat exports intact. (#49504) Thanks @scoootscooob.
|
||||
@@ -204,14 +222,21 @@ Docs: https://docs.openclaw.ai
|
||||
- Web search: align onboarding, configure, and finalize with plugin-owned provider contracts, including disabled-provider recovery, config-aware credential hooks, and runtime-visible summaries. (#50935) Thanks @gumadeiras.
|
||||
- Agents/replay: sanitize malformed assistant tool-call replay blocks before provider replay so follow-up Anthropic requests do not inherit the downstream `replace` crash. (#50005) Thanks @jalehman.
|
||||
- Plugins/context engines: retry strict legacy `assemble()` calls without the new `prompt` field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan.
|
||||
- make `openclaw update status` explicitly say `up to date` when the local version already matches npm latest, while keeping the availability logic unchanged. (#51409) Thanks @dongzhenye.
|
||||
- Agents/embedded transport errors: distinguish common network failures like connection refused, DNS lookup failure, and interrupted sockets from true timeouts in embedded-run user messaging and lifecycle diagnostics. (#51419) Thanks @scoootscooob.
|
||||
- Discord/startup logging: report client initialization while the gateway is still connecting instead of claiming Discord is logged in before readiness is reached. (#51425) Thanks @scoootscooob.
|
||||
- Discord/startup logging: report client initialization while the gateway is still connecting instead of claiming Discord is logged in before readiness is reached. (#51425) Thanks @scoootscoob.
|
||||
- Gateway/probe: honor caller `--timeout` for active local loopback probes in `gateway status`, keep inactive remote-mode loopback probes fast, and clamp probe timers to JS-safe bounds so slow local/container gateways stop reporting false timeouts. (#47533) Thanks @MonkeyLeeT.
|
||||
- Config/startup: keep bundled web-search allowlist compatibility on a lightweight manifest path so config validation no longer pulls bundled web-search registry imports into startup, while still avoiding accidental auto-allow of config-loaded override plugins. (#51574) Thanks @RichardCao.
|
||||
- Gateway/chat.send: persist uploaded image references across reloads and compaction without delaying first-turn dispatch or double-submitting the same image to vision models. (#51324) Thanks @fuller-stack-dev.
|
||||
- Plugins/runtime state: share plugin-facing infra singleton state across duplicate module graphs and keep session-binding adapter ownership stable until the active owner unregisters. (#50725) thanks @huntharo.
|
||||
- Agents/compaction safeguard: preserve split-turn context and preserved recent turns when capped retry fallback reuses the last successful summary. (#27727) thanks @Pandadadadazxf.
|
||||
- Discord/pickers: keep `/codex_resume --browse-projects` picker callbacks alive in Discord by sharing component callback state across duplicate module graphs, preserving callback fallbacks, and acknowledging matched plugin interactions before dispatch. (#51260) Thanks @huntharo.
|
||||
- Agents/memory flush: keep transcript-hash dedup active across memory-flush fallback retries so a write-then-throw flush attempt cannot append duplicate `MEMORY.md` entries before the fallback cycle completes. (#34222) Thanks @lml2468.
|
||||
- make `openclaw update status` explicitly say `up to date` when the local version already matches npm latest, while keeping the availability logic unchanged. (#51409) Thanks @dongzhenye.
|
||||
- Android/canvas: recycle captured and scaled snapshot bitmaps so repeated canvas snapshots do not leak native image memory. (#41889) Thanks @Kaneki-x.
|
||||
- Android/theme: switch status bar icon contrast with the active system theme so Android light mode no longer leaves unreadable light icons over the app header. (#51098) Thanks @goweii.
|
||||
- Discord/ACP: forward worker abort signals into ACP turns so timed-out Discord jobs cancel the running turn instead of silently leaving the bound ACP session working in the background.
|
||||
- Gateway/openresponses: preserve assistant commentary and session continuity across hosted-tool `/v1/responses` turns, and emit streamed tool-call payloads before finalization so client tool loops stay resumable. (#52171) Thanks @CharZhou.
|
||||
|
||||
### Breaking
|
||||
|
||||
@@ -226,6 +251,8 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/Matrix: add a new Matrix plugin backed by the official `matrix-js-sdk`. If you are upgrading from the previous public Matrix plugin, follow the migration guide: https://docs.openclaw.ai/install/migrating-matrix Thanks @gumadeiras.
|
||||
- Discord/commands: switch native command deployment to Carbon reconcile by default so Discord restarts stop churning slash commands through OpenClaw’s local deploy path. (#46597) Thanks @huntharo and @thewilloftheshadow.
|
||||
- Plugins/Matrix: durably dedupe inbound room events across gateway restarts so previously handled Matrix messages are not replayed as new, while preserving clean-restart backlog delivery for unseen events. (#50922) thanks @gumadeiras
|
||||
- Agents/media replies: migrate the remaining browser, canvas, and nodes snapshot outputs onto `details.media` so generated media keeps attaching to assistant replies after the collect-then-attach refactor. (#51731) Thanks @christianklotz.
|
||||
- Android/contacts search: escape literal `%` and `_` in contact-name queries so searches like `100%` or `_id` no longer match unrelated contacts through SQL `LIKE` wildcards. (#41891) Thanks @Kaneki-x.
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
@@ -406,6 +433,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Control UI/auth: restore one-time legacy `?token=` imports for shared Control UI links while keeping `#token=` preferred, and carry pending query tokens through gateway URL confirmation so compatibility links still authenticate after confirmation. (#43979) Thanks @stim64045-spec.
|
||||
- Plugins/context engines: retry legacy lifecycle calls once without `sessionKey` when older plugins reject that field, memoize legacy mode after the first strict-schema fallback, and preserve non-compat runtime errors without retry. (#44779) thanks @hhhhao28.
|
||||
- Agents/compaction: treat markup-wrapped heartbeat boilerplate as non-meaningful session history when deciding whether to compact, so heartbeat-only sessions no longer keep compaction alive due to wrapper formatting. (#42119) thanks @samzong.
|
||||
|
||||
## 2026.3.11
|
||||
|
||||
@@ -541,6 +569,8 @@ Docs: https://docs.openclaw.ai
|
||||
- macOS/remote gateway: stop PortGuardian from killing Docker Desktop and other external listeners on the gateway port in remote mode, so containerized and tunneled gateway setups no longer lose their port-forward owner on app startup. (#6755) Thanks @teslamint.
|
||||
- Feishu/streaming recovery: clear stale `streamingStartPromise` when card creation fails (HTTP 400) so subsequent messages can retry streaming instead of silently dropping all future replies. Fixes #43322.
|
||||
- Exec/env sandbox: block JVM agent injection (`JAVA_TOOL_OPTIONS`, `_JAVA_OPTIONS`, `JDK_JAVA_OPTIONS`), Python breakpoint hijack (`PYTHONBREAKPOINT`), and .NET startup hooks (`DOTNET_STARTUP_HOOKS`) from the host exec environment. (#49025)
|
||||
- Android/camera clip cleanup: delete temporary clip files even when `readBytes()` fails so failed clip captures do not leak cache storage. (#41890) Thanks @Kaneki-x.
|
||||
- Android/photos: recycle decoded and intermediate bitmaps in `photos.latest` so repeated photo fetches stop leaking native memory. (#41888) Thanks @Kaneki-x.
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.content.pm.PackageManager
|
||||
import android.content.Intent
|
||||
import android.Manifest
|
||||
import android.net.Uri
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.activity.ComponentActivity
|
||||
@@ -11,17 +13,21 @@ import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class PermissionRequester(private val activity: ComponentActivity) {
|
||||
private val mutex = Mutex()
|
||||
private var pending: CompletableDeferred<Map<String, Boolean>>? = null
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private val launcher: ActivityResultLauncher<Array<String>> =
|
||||
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
|
||||
@@ -86,32 +92,84 @@ class PermissionRequester(private val activity: ComponentActivity) {
|
||||
|
||||
private suspend fun showRationaleDialog(permissions: List<String>): Boolean =
|
||||
withContext(Dispatchers.Main) {
|
||||
if (activity.isFinishing || activity.isDestroyed) {
|
||||
return@withContext false
|
||||
}
|
||||
suspendCancellableCoroutine { cont ->
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Permission required")
|
||||
.setMessage(buildRationaleMessage(permissions))
|
||||
.setPositiveButton("Continue") { _, _ -> cont.resume(true) }
|
||||
.setNegativeButton("Not now") { _, _ -> cont.resume(false) }
|
||||
.setOnCancelListener { cont.resume(false) }
|
||||
.show()
|
||||
val lifecycle = activity.lifecycle
|
||||
var dialog: AlertDialog? = null
|
||||
var observer: LifecycleEventObserver? = null
|
||||
val finished = AtomicBoolean(false)
|
||||
val removeObserver = {
|
||||
observer?.let(lifecycle::removeObserver)
|
||||
observer = null
|
||||
}
|
||||
fun finish(result: Boolean?) {
|
||||
if (!finished.compareAndSet(false, true)) return
|
||||
removeObserver()
|
||||
dialog?.dismiss()
|
||||
if (result != null) {
|
||||
cont.resume(result)
|
||||
}
|
||||
}
|
||||
val actualObserver =
|
||||
LifecycleEventObserver { _, event ->
|
||||
if (event != Lifecycle.Event.ON_DESTROY) return@LifecycleEventObserver
|
||||
finish(false)
|
||||
}
|
||||
observer = actualObserver
|
||||
lifecycle.addObserver(actualObserver)
|
||||
cont.invokeOnCancellation {
|
||||
mainHandler.post {
|
||||
finish(null)
|
||||
}
|
||||
}
|
||||
dialog =
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Permission required")
|
||||
.setMessage(buildRationaleMessage(permissions))
|
||||
.setPositiveButton("Continue") { _, _ -> finish(true) }
|
||||
.setNegativeButton("Not now") { _, _ -> finish(false) }
|
||||
.setOnCancelListener { finish(false) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSettingsDialog(permissions: List<String>) {
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Enable permission in Settings")
|
||||
.setMessage(buildSettingsMessage(permissions))
|
||||
.setPositiveButton("Open Settings") { _, _ ->
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", activity.packageName, null),
|
||||
)
|
||||
activity.startActivity(intent)
|
||||
private suspend fun showSettingsDialog(permissions: List<String>) =
|
||||
withContext(Dispatchers.Main) {
|
||||
if (activity.isFinishing || activity.isDestroyed) return@withContext
|
||||
val lifecycle = activity.lifecycle
|
||||
var dialog: AlertDialog? = null
|
||||
var observer: LifecycleEventObserver? = null
|
||||
val removeObserver = {
|
||||
observer?.let(lifecycle::removeObserver)
|
||||
observer = null
|
||||
}
|
||||
.setNegativeButton("Cancel", null)
|
||||
.show()
|
||||
}
|
||||
val actualObserver =
|
||||
LifecycleEventObserver { _, event ->
|
||||
if (event != Lifecycle.Event.ON_DESTROY) return@LifecycleEventObserver
|
||||
removeObserver()
|
||||
dialog?.dismiss()
|
||||
}
|
||||
observer = actualObserver
|
||||
lifecycle.addObserver(actualObserver)
|
||||
dialog =
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Enable permission in Settings")
|
||||
.setMessage(buildSettingsMessage(permissions))
|
||||
.setPositiveButton("Open Settings") { _, _ ->
|
||||
if (activity.isFinishing || activity.isDestroyed) return@setPositiveButton
|
||||
val intent =
|
||||
Intent(
|
||||
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
|
||||
Uri.fromParts("package", activity.packageName, null),
|
||||
)
|
||||
activity.startActivity(intent)
|
||||
}
|
||||
.setNegativeButton("Cancel", null)
|
||||
.setOnDismissListener { removeObserver() }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun buildRationaleMessage(permissions: List<String>): String {
|
||||
val labels = permissions.map { permissionLabel(it) }
|
||||
|
||||
@@ -121,42 +121,48 @@ class CameraCaptureManager(private val context: Context) {
|
||||
(rotated.height.toDouble() * (maxWidth.toDouble() / rotated.width.toDouble()))
|
||||
.toInt()
|
||||
.coerceAtLeast(1)
|
||||
rotated.scale(maxWidth, h)
|
||||
val s = rotated.scale(maxWidth, h)
|
||||
if (s !== rotated) rotated.recycle()
|
||||
s
|
||||
} else {
|
||||
rotated
|
||||
}
|
||||
|
||||
val maxPayloadBytes = 5 * 1024 * 1024
|
||||
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
|
||||
val maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||
val result =
|
||||
JpegSizeLimiter.compressToLimit(
|
||||
initialWidth = scaled.width,
|
||||
initialHeight = scaled.height,
|
||||
startQuality = (quality * 100.0).roundToInt().coerceIn(10, 100),
|
||||
maxBytes = maxEncodedBytes,
|
||||
encode = { width, height, q ->
|
||||
val bitmap =
|
||||
if (width == scaled.width && height == scaled.height) {
|
||||
scaled
|
||||
} else {
|
||||
scaled.scale(width, height)
|
||||
try {
|
||||
val maxPayloadBytes = 5 * 1024 * 1024
|
||||
// Base64 inflates payloads by ~4/3; cap encoded bytes so the payload stays under 5MB (API limit).
|
||||
val maxEncodedBytes = (maxPayloadBytes / 4) * 3
|
||||
val result =
|
||||
JpegSizeLimiter.compressToLimit(
|
||||
initialWidth = scaled.width,
|
||||
initialHeight = scaled.height,
|
||||
startQuality = (quality * 100.0).roundToInt().coerceIn(10, 100),
|
||||
maxBytes = maxEncodedBytes,
|
||||
encode = { width, height, q ->
|
||||
val bitmap =
|
||||
if (width == scaled.width && height == scaled.height) {
|
||||
scaled
|
||||
} else {
|
||||
scaled.scale(width, height)
|
||||
}
|
||||
val out = ByteArrayOutputStream()
|
||||
if (!bitmap.compress(Bitmap.CompressFormat.JPEG, q, out)) {
|
||||
if (bitmap !== scaled) bitmap.recycle()
|
||||
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
|
||||
}
|
||||
val out = ByteArrayOutputStream()
|
||||
if (!bitmap.compress(Bitmap.CompressFormat.JPEG, q, out)) {
|
||||
if (bitmap !== scaled) bitmap.recycle()
|
||||
throw IllegalStateException("UNAVAILABLE: failed to encode JPEG")
|
||||
}
|
||||
if (bitmap !== scaled) {
|
||||
bitmap.recycle()
|
||||
}
|
||||
out.toByteArray()
|
||||
},
|
||||
if (bitmap !== scaled) {
|
||||
bitmap.recycle()
|
||||
}
|
||||
out.toByteArray()
|
||||
},
|
||||
)
|
||||
val base64 = Base64.encodeToString(result.bytes, Base64.NO_WRAP)
|
||||
Payload(
|
||||
"""{"format":"jpg","base64":"$base64","width":${result.width},"height":${result.height}}""",
|
||||
)
|
||||
val base64 = Base64.encodeToString(result.bytes, Base64.NO_WRAP)
|
||||
Payload(
|
||||
"""{"format":"jpg","base64":"$base64","width":${result.width},"height":${result.height}}""",
|
||||
)
|
||||
} finally {
|
||||
scaled.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
|
||||
@@ -134,9 +134,11 @@ class CameraHandler(
|
||||
}
|
||||
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
val b = filePayload.file.readBytes()
|
||||
filePayload.file.delete()
|
||||
b
|
||||
try {
|
||||
filePayload.file.readBytes()
|
||||
} finally {
|
||||
filePayload.file.delete()
|
||||
}
|
||||
}
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
clipLog("returning base64 payload")
|
||||
|
||||
@@ -180,27 +180,41 @@ class CanvasController {
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
val bmp = wv.captureBitmap()
|
||||
val scaled = bmp.scaleForMaxWidth(maxWidth)
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||
try {
|
||||
val scaled = bmp.scaleForMaxWidth(maxWidth)
|
||||
try {
|
||||
val out = ByteArrayOutputStream()
|
||||
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
|
||||
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||
} finally {
|
||||
if (scaled !== bmp) scaled.recycle()
|
||||
}
|
||||
} finally {
|
||||
bmp.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun snapshotBase64(format: SnapshotFormat, quality: Double?, maxWidth: Int?): String =
|
||||
withContext(Dispatchers.Main) {
|
||||
val wv = webView ?: throw IllegalStateException("no webview")
|
||||
val bmp = wv.captureBitmap()
|
||||
val scaled = bmp.scaleForMaxWidth(maxWidth)
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
val (compressFormat, compressQuality) =
|
||||
when (format) {
|
||||
SnapshotFormat.Png -> Bitmap.CompressFormat.PNG to 100
|
||||
SnapshotFormat.Jpeg -> Bitmap.CompressFormat.JPEG to clampJpegQuality(quality)
|
||||
try {
|
||||
val scaled = bmp.scaleForMaxWidth(maxWidth)
|
||||
try {
|
||||
val out = ByteArrayOutputStream()
|
||||
val (compressFormat, compressQuality) =
|
||||
when (format) {
|
||||
SnapshotFormat.Png -> Bitmap.CompressFormat.PNG to 100
|
||||
SnapshotFormat.Jpeg -> Bitmap.CompressFormat.JPEG to clampJpegQuality(quality)
|
||||
}
|
||||
scaled.compress(compressFormat, compressQuality, out)
|
||||
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||
} finally {
|
||||
if (scaled !== bmp) scaled.recycle()
|
||||
}
|
||||
scaled.compress(compressFormat, compressQuality, out)
|
||||
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
|
||||
} finally {
|
||||
bmp.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun WebView.captureBitmap(): Bitmap =
|
||||
|
||||
@@ -76,8 +76,8 @@ private object SystemContactsDataSource : ContactsDataSource {
|
||||
selection = null
|
||||
selectionArgs = null
|
||||
} else {
|
||||
selection = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} LIKE ?"
|
||||
selectionArgs = arrayOf("%${request.query}%")
|
||||
selection = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} LIKE ? ESCAPE '\\'"
|
||||
selectionArgs = arrayOf("%${escapeLikePattern(request.query)}%")
|
||||
}
|
||||
val sortOrder = "${ContactsContract.Contacts.DISPLAY_NAME_PRIMARY} COLLATE NOCASE ASC LIMIT ${request.limit}"
|
||||
resolver.query(
|
||||
@@ -247,6 +247,9 @@ private object SystemContactsDataSource : ContactsDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
private fun escapeLikePattern(pattern: String): String =
|
||||
pattern.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
private fun loadPhones(resolver: ContentResolver, contactId: Long): List<String> {
|
||||
return queryContactValues(
|
||||
resolver = resolver,
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.os.SystemClock
|
||||
import androidx.core.content.ContextCompat
|
||||
import ai.openclaw.app.gateway.GatewaySession
|
||||
import java.time.Instant
|
||||
import kotlinx.coroutines.InternalCoroutinesApi
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
@@ -18,7 +19,6 @@ import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.sqrt
|
||||
@@ -142,19 +142,18 @@ private object SystemMotionDataSource : MotionDataSource {
|
||||
val averageDelta: Double,
|
||||
)
|
||||
|
||||
@OptIn(InternalCoroutinesApi::class)
|
||||
private suspend fun readStepCounter(sensorManager: SensorManager, sensor: Sensor): Int? {
|
||||
val sample =
|
||||
withTimeoutOrNull(1200L) {
|
||||
suspendCancellableCoroutine<Float?> { cont ->
|
||||
var resumed = false
|
||||
val listener =
|
||||
object : SensorEventListener {
|
||||
override fun onSensorChanged(event: SensorEvent?) {
|
||||
if (resumed) return
|
||||
val value = event?.values?.firstOrNull()
|
||||
resumed = true
|
||||
val token = cont.tryResume(value) ?: return
|
||||
cont.completeResume(token)
|
||||
sensorManager.unregisterListener(this)
|
||||
cont.resume(value)
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
@@ -162,8 +161,7 @@ private object SystemMotionDataSource : MotionDataSource {
|
||||
val registered = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
if (!registered) {
|
||||
sensorManager.unregisterListener(listener)
|
||||
resumed = true
|
||||
cont.resume(null)
|
||||
cont.resume(null) { _, _, _ -> }
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
cont.invokeOnCancellation { sensorManager.unregisterListener(listener) }
|
||||
@@ -172,6 +170,7 @@ private object SystemMotionDataSource : MotionDataSource {
|
||||
return sample?.toInt()?.takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
@OptIn(InternalCoroutinesApi::class)
|
||||
private suspend fun readAccelerometerSample(
|
||||
sensorManager: SensorManager,
|
||||
sensor: Sensor,
|
||||
@@ -181,7 +180,6 @@ private object SystemMotionDataSource : MotionDataSource {
|
||||
suspendCancellableCoroutine<AccelerometerSample?> { cont ->
|
||||
var count = 0
|
||||
var sumDelta = 0.0
|
||||
var resumed = false
|
||||
val listener =
|
||||
object : SensorEventListener {
|
||||
override fun onSensorChanged(event: SensorEvent?) {
|
||||
@@ -195,15 +193,14 @@ private object SystemMotionDataSource : MotionDataSource {
|
||||
).toDouble()
|
||||
sumDelta += abs(magnitude - SensorManager.GRAVITY_EARTH.toDouble())
|
||||
count += 1
|
||||
if (count >= ACCELEROMETER_SAMPLE_TARGET && !resumed) {
|
||||
resumed = true
|
||||
sensorManager.unregisterListener(this)
|
||||
cont.resume(
|
||||
AccelerometerSample(
|
||||
samples = count,
|
||||
averageDelta = if (count == 0) 0.0 else sumDelta / count,
|
||||
),
|
||||
if (count >= ACCELEROMETER_SAMPLE_TARGET) {
|
||||
val result = AccelerometerSample(
|
||||
samples = count,
|
||||
averageDelta = sumDelta / count,
|
||||
)
|
||||
val token = cont.tryResume(result) ?: return
|
||||
cont.completeResume(token)
|
||||
sensorManager.unregisterListener(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,8 +208,7 @@ private object SystemMotionDataSource : MotionDataSource {
|
||||
}
|
||||
val registered = sensorManager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
if (!registered) {
|
||||
resumed = true
|
||||
cont.resume(null)
|
||||
cont.resume(null) { _, _, _ -> }
|
||||
return@suspendCancellableCoroutine
|
||||
}
|
||||
cont.invokeOnCancellation { sensorManager.unregisterListener(listener) }
|
||||
|
||||
@@ -71,17 +71,22 @@ private object SystemPhotosDataSource : PhotosDataSource {
|
||||
for (row in rows) {
|
||||
if (remainingBudget <= 0) break
|
||||
val bitmap = decodeScaledBitmap(resolver, row.uri, request.maxWidth) ?: continue
|
||||
val encoded = encodeJpegUnderBudget(bitmap, request.quality, MAX_PER_PHOTO_BASE64_CHARS) ?: continue
|
||||
if (encoded.base64.length > remainingBudget) break
|
||||
remainingBudget -= encoded.base64.length
|
||||
out +=
|
||||
EncodedPhotoPayload(
|
||||
format = "jpeg",
|
||||
base64 = encoded.base64,
|
||||
width = encoded.width,
|
||||
height = encoded.height,
|
||||
createdAt = row.createdAtMs?.let { Instant.ofEpochMilli(it).toString() },
|
||||
)
|
||||
try {
|
||||
val encoded = encodeJpegUnderBudget(bitmap, request.quality, MAX_PER_PHOTO_BASE64_CHARS)
|
||||
if (encoded == null) continue
|
||||
if (encoded.base64.length > remainingBudget) break
|
||||
remainingBudget -= encoded.base64.length
|
||||
out +=
|
||||
EncodedPhotoPayload(
|
||||
format = "jpeg",
|
||||
base64 = encoded.base64,
|
||||
width = encoded.width,
|
||||
height = encoded.height,
|
||||
createdAt = row.createdAtMs?.let { Instant.ofEpochMilli(it).toString() },
|
||||
)
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -159,7 +164,11 @@ private object SystemPhotosDataSource : PhotosDataSource {
|
||||
|
||||
if (decoded.width <= maxWidth) return decoded
|
||||
val targetHeight = max(1, ((decoded.height.toDouble() * maxWidth) / decoded.width).roundToInt())
|
||||
return decoded.scale(maxWidth, targetHeight, true)
|
||||
return try {
|
||||
decoded.scale(maxWidth, targetHeight, true)
|
||||
} finally {
|
||||
decoded.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeInSampleSize(width: Int, maxWidth: Int): Int {
|
||||
@@ -178,30 +187,36 @@ private object SystemPhotosDataSource : PhotosDataSource {
|
||||
maxBase64Chars: Int,
|
||||
): EncodedJpeg? {
|
||||
var working = bitmap
|
||||
var jpegQuality = (quality.coerceIn(0.1, 1.0) * 100.0).roundToInt().coerceIn(10, 100)
|
||||
repeat(10) {
|
||||
val out = ByteArrayOutputStream()
|
||||
val ok = working.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)
|
||||
if (!ok) return null
|
||||
val bytes = out.toByteArray()
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
if (base64.length <= maxBase64Chars) {
|
||||
return EncodedJpeg(
|
||||
base64 = base64,
|
||||
width = working.width,
|
||||
height = working.height,
|
||||
)
|
||||
try {
|
||||
var jpegQuality = (quality.coerceIn(0.1, 1.0) * 100.0).roundToInt().coerceIn(10, 100)
|
||||
repeat(10) {
|
||||
val out = ByteArrayOutputStream()
|
||||
val ok = working.compress(Bitmap.CompressFormat.JPEG, jpegQuality, out)
|
||||
if (!ok) return null
|
||||
val bytes = out.toByteArray()
|
||||
val base64 = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP)
|
||||
if (base64.length <= maxBase64Chars) {
|
||||
return EncodedJpeg(
|
||||
base64 = base64,
|
||||
width = working.width,
|
||||
height = working.height,
|
||||
)
|
||||
}
|
||||
if (jpegQuality > 35) {
|
||||
jpegQuality = max(25, jpegQuality - 15)
|
||||
return@repeat
|
||||
}
|
||||
val nextWidth = max(240, (working.width * 0.75f).roundToInt())
|
||||
if (nextWidth >= working.width) return null
|
||||
val nextHeight = max(1, ((working.height.toDouble() * nextWidth) / working.width).roundToInt())
|
||||
val previous = working
|
||||
working = working.scale(nextWidth, nextHeight, true)
|
||||
if (previous !== bitmap) previous.recycle()
|
||||
}
|
||||
if (jpegQuality > 35) {
|
||||
jpegQuality = max(25, jpegQuality - 15)
|
||||
return@repeat
|
||||
}
|
||||
val nextWidth = max(240, (working.width * 0.75f).roundToInt())
|
||||
if (nextWidth >= working.width) return null
|
||||
val nextHeight = max(1, ((working.height.toDouble() * nextWidth) / working.width).roundToInt())
|
||||
working = working.scale(nextWidth, nextHeight, true)
|
||||
return null
|
||||
} finally {
|
||||
if (working !== bitmap) working.recycle()
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,9 +58,12 @@ object OpenClawCanvasA2UIAction {
|
||||
}
|
||||
|
||||
fun jsDispatchA2UIActionStatus(actionId: String, ok: Boolean, error: String?): String {
|
||||
val err = (error ?: "").replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
val err = jsonStringLiteral(error ?: "")
|
||||
val okLiteral = if (ok) "true" else "false"
|
||||
val idEscaped = actionId.replace("\\", "\\\\").replace("\"", "\\\"")
|
||||
return "window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail: { id: \"${idEscaped}\", ok: ${okLiteral}, error: \"${err}\" } }));"
|
||||
val idLiteral = jsonStringLiteral(actionId)
|
||||
return "window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail: { id: ${idLiteral}, ok: ${okLiteral}, error: ${err} } }));"
|
||||
}
|
||||
|
||||
private fun jsonStringLiteral(raw: String): String =
|
||||
JsonPrimitive(raw).toString().replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
|
||||
}
|
||||
|
||||
@@ -97,8 +97,25 @@ internal fun parseGatewayEndpoint(rawInput: String): GatewayEndpointConfig? {
|
||||
"wss", "https" -> true
|
||||
else -> true
|
||||
}
|
||||
val port = uri.port.takeIf { it in 1..65535 } ?: if (tls) 443 else 18789
|
||||
val displayUrl = "${if (tls) "https" else "http"}://$host:$port"
|
||||
val defaultPort =
|
||||
when (scheme) {
|
||||
"wss", "https" -> 443
|
||||
"ws", "http" -> 18789
|
||||
else -> 443
|
||||
}
|
||||
val displayPort =
|
||||
when (scheme) {
|
||||
"wss", "https" -> 443
|
||||
"ws", "http" -> 80
|
||||
else -> 443
|
||||
}
|
||||
val port = uri.port.takeIf { it in 1..65535 } ?: defaultPort
|
||||
val displayUrl =
|
||||
if (port == displayPort && defaultPort == displayPort) {
|
||||
"${if (tls) "https" else "http"}://$host"
|
||||
} else {
|
||||
"${if (tls) "https" else "http"}://$host:$port"
|
||||
}
|
||||
|
||||
return GatewayEndpointConfig(host = host, port = port, tls = tls, displayUrl = displayUrl)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.dynamicDarkColorScheme
|
||||
import androidx.compose.material3.dynamicLightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
|
||||
@Composable
|
||||
fun OpenClawTheme(content: @Composable () -> Unit) {
|
||||
@@ -16,6 +20,15 @@ fun OpenClawTheme(content: @Composable () -> Unit) {
|
||||
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
val mobileColors = if (isDark) darkMobileColors() else lightMobileColors()
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val window = (view.context as Activity).window
|
||||
WindowCompat.getInsetsController(window, window.decorView)
|
||||
.isAppearanceLightStatusBars = !isDark
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(LocalMobileColors provides mobileColors) {
|
||||
MaterialTheme(colorScheme = colorScheme, content = content)
|
||||
}
|
||||
|
||||
@@ -46,4 +46,18 @@ class OpenClawCanvasA2UIActionTest {
|
||||
js,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun jsDispatchA2uiStatusQuotesControlCharacters() {
|
||||
val js =
|
||||
OpenClawCanvasA2UIAction.jsDispatchA2UIActionStatus(
|
||||
actionId = "a1\n\u2028\"",
|
||||
ok = false,
|
||||
error = "parse failed\n\t\u2029\\",
|
||||
)
|
||||
assertEquals(
|
||||
"window.dispatchEvent(new CustomEvent('openclaw:a2ui-action-status', { detail: { id: \"a1\\n\\u2028\\\"\", ok: false, error: \"parse failed\\n\\t\\u2029\\\\\" } }));",
|
||||
js,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,86 @@ import java.util.Base64
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class GatewayConfigResolverTest {
|
||||
@Test
|
||||
fun parseGatewayEndpointUsesDefaultTlsPortForBareWssUrls() {
|
||||
val parsed = parseGatewayEndpoint("wss://gateway.example")
|
||||
|
||||
assertEquals(
|
||||
GatewayEndpointConfig(
|
||||
host = "gateway.example",
|
||||
port = 443,
|
||||
tls = true,
|
||||
displayUrl = "https://gateway.example",
|
||||
),
|
||||
parsed,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseGatewayEndpointUsesDefaultCleartextPortForBareWsUrls() {
|
||||
val parsed = parseGatewayEndpoint("ws://gateway.example")
|
||||
|
||||
assertEquals(
|
||||
GatewayEndpointConfig(
|
||||
host = "gateway.example",
|
||||
port = 18789,
|
||||
tls = false,
|
||||
displayUrl = "http://gateway.example:18789",
|
||||
),
|
||||
parsed,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseGatewayEndpointOmitsExplicitDefaultTlsPortFromDisplayUrl() {
|
||||
val parsed = parseGatewayEndpoint("https://gateway.example:443")
|
||||
|
||||
assertEquals(
|
||||
GatewayEndpointConfig(
|
||||
host = "gateway.example",
|
||||
port = 443,
|
||||
tls = true,
|
||||
displayUrl = "https://gateway.example",
|
||||
),
|
||||
parsed,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseGatewayEndpointKeepsExplicitNonDefaultPortInDisplayUrl() {
|
||||
val parsed = parseGatewayEndpoint("http://gateway.example:8080")
|
||||
|
||||
assertEquals(
|
||||
GatewayEndpointConfig(
|
||||
host = "gateway.example",
|
||||
port = 8080,
|
||||
tls = false,
|
||||
displayUrl = "http://gateway.example:8080",
|
||||
),
|
||||
parsed,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseGatewayEndpointKeepsExplicitCleartextPort80InDisplayUrl() {
|
||||
val parsed = parseGatewayEndpoint("http://gateway.example:80")
|
||||
|
||||
assertEquals(
|
||||
GatewayEndpointConfig(
|
||||
host = "gateway.example",
|
||||
port = 80,
|
||||
tls = false,
|
||||
displayUrl = "http://gateway.example:80",
|
||||
),
|
||||
parsed,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolveScannedSetupCodeAcceptsRawSetupCode() {
|
||||
val setupCode =
|
||||
|
||||
@@ -783,7 +783,7 @@ extension AppState {
|
||||
remoteToken: String,
|
||||
remoteTokenDirty: Bool) -> [String: Any]
|
||||
{
|
||||
Self.updatedRemoteGatewayConfig(
|
||||
updatedRemoteGatewayConfig(
|
||||
current: current,
|
||||
transport: transport,
|
||||
remoteUrl: remoteUrl,
|
||||
@@ -791,7 +791,8 @@ extension AppState {
|
||||
remoteTarget: remoteTarget,
|
||||
remoteIdentity: remoteIdentity,
|
||||
remoteToken: remoteToken,
|
||||
remoteTokenDirty: remoteTokenDirty).remote
|
||||
remoteTokenDirty: remoteTokenDirty
|
||||
).remote
|
||||
}
|
||||
|
||||
static func _testSyncedGatewayRoot(
|
||||
@@ -804,7 +805,7 @@ extension AppState {
|
||||
remoteToken: String,
|
||||
remoteTokenDirty: Bool) -> [String: Any]
|
||||
{
|
||||
Self.syncedGatewayRoot(
|
||||
syncedGatewayRoot(
|
||||
currentRoot: currentRoot,
|
||||
connectionMode: connectionMode,
|
||||
remoteTransport: remoteTransport,
|
||||
@@ -812,7 +813,8 @@ extension AppState {
|
||||
remoteIdentity: remoteIdentity,
|
||||
remoteUrl: remoteUrl,
|
||||
remoteToken: remoteToken,
|
||||
remoteTokenDirty: remoteTokenDirty).root
|
||||
remoteTokenDirty: remoteTokenDirty
|
||||
).root
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -8,8 +8,8 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
static let messageName = "openclawCanvasA2UIAction"
|
||||
static let allMessageNames = [messageName]
|
||||
|
||||
// Compatibility helper for debug/test shims. Runtime dispatch remains
|
||||
// limited to in-app canvas schemes in `didReceive`.
|
||||
// Compatibility helper for debug/test shims.
|
||||
// Runtime dispatch remains limited to in-app canvas schemes in `didReceive`.
|
||||
static func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
|
||||
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
|
||||
return false
|
||||
|
||||
@@ -54,11 +54,13 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
|
||||
// expose unattended deep-link credentials to page JavaScript.
|
||||
canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script")
|
||||
let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main"
|
||||
let allowedSchemesJSON = (
|
||||
try? String(
|
||||
data: JSONSerialization.data(withJSONObject: CanvasScheme.allSchemes),
|
||||
encoding: .utf8)
|
||||
) ?? "[]"
|
||||
let allowedSchemesJSON =
|
||||
(
|
||||
try? String(
|
||||
data: JSONSerialization.data(withJSONObject: CanvasScheme.allSchemes),
|
||||
encoding: .utf8
|
||||
)
|
||||
) ?? "[]"
|
||||
let bridgeScript = """
|
||||
(() => {
|
||||
try {
|
||||
|
||||
@@ -147,7 +147,8 @@ extension CronJobEditor {
|
||||
throw NSError(
|
||||
domain: "Cron",
|
||||
code: 0,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."])
|
||||
userInfo: [NSLocalizedDescriptionKey: "Invalid every duration (use 10m, 1h, 1d)."]
|
||||
)
|
||||
}
|
||||
return ["kind": "every", "everyMs": ms]
|
||||
case .cron:
|
||||
@@ -265,7 +266,11 @@ extension CronJobEditor {
|
||||
}
|
||||
|
||||
var effectiveSessionTargetRaw: String {
|
||||
if self.sessionTarget == .isolated, let preserved = self.preservedSessionTargetRaw?.trimmingCharacters(in: .whitespacesAndNewlines), !preserved.isEmpty {
|
||||
if self.sessionTarget == .isolated,
|
||||
let preserved = self.preservedSessionTargetRaw?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!preserved.isEmpty
|
||||
{
|
||||
return preserved
|
||||
}
|
||||
return self.sessionTarget.rawValue
|
||||
|
||||
@@ -16,10 +16,10 @@ enum CronCustomSessionTarget: Codable, Equatable {
|
||||
|
||||
var rawValue: String {
|
||||
switch self {
|
||||
case .predefined(let target):
|
||||
return target.rawValue
|
||||
case .session(let id):
|
||||
return "session:\(id)"
|
||||
case let .predefined(target):
|
||||
target.rawValue
|
||||
case let .session(id):
|
||||
"session:\(id)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,20 +96,24 @@ enum CronSchedule: Codable, Equatable {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .at,
|
||||
in: container,
|
||||
debugDescription: "Missing schedule.at")
|
||||
debugDescription: "Missing schedule.at"
|
||||
)
|
||||
case "every":
|
||||
self = try .every(
|
||||
everyMs: container.decode(Int.self, forKey: .everyMs),
|
||||
anchorMs: container.decodeIfPresent(Int.self, forKey: .anchorMs))
|
||||
anchorMs: container.decodeIfPresent(Int.self, forKey: .anchorMs)
|
||||
)
|
||||
case "cron":
|
||||
self = try .cron(
|
||||
expr: container.decode(String.self, forKey: .expr),
|
||||
tz: container.decodeIfPresent(String.self, forKey: .tz))
|
||||
tz: container.decodeIfPresent(String.self, forKey: .tz)
|
||||
)
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .kind,
|
||||
in: container,
|
||||
debugDescription: "Unknown schedule kind: \(kind)")
|
||||
debugDescription: "Unknown schedule kind: \(kind)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,12 +189,14 @@ enum CronPayload: Codable, Equatable {
|
||||
channel: container.decodeIfPresent(String.self, forKey: .channel)
|
||||
?? container.decodeIfPresent(String.self, forKey: .provider),
|
||||
to: container.decodeIfPresent(String.self, forKey: .to),
|
||||
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver))
|
||||
bestEffortDeliver: container.decodeIfPresent(Bool.self, forKey: .bestEffortDeliver)
|
||||
)
|
||||
default:
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .kind,
|
||||
in: container,
|
||||
debugDescription: "Unknown payload kind: \(kind)")
|
||||
debugDescription: "Unknown payload kind: \(kind)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,10 +334,10 @@ struct CronJob: Identifiable, Codable, Equatable {
|
||||
/// predefined enum.
|
||||
var sessionTarget: CronSessionTarget {
|
||||
switch self.parsedSessionTarget {
|
||||
case .predefined(let target):
|
||||
return target
|
||||
case let .predefined(target):
|
||||
target
|
||||
case .session:
|
||||
return .isolated
|
||||
.isolated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,8 +351,8 @@ struct CronJob: Identifiable, Codable, Equatable {
|
||||
return nil
|
||||
case .predefined(.isolated), .predefined(.current):
|
||||
return "cron:\(self.id)"
|
||||
case .session(let id):
|
||||
return id
|
||||
case let .session(id):
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,7 +367,7 @@ struct CronJob: Identifiable, Codable, Equatable {
|
||||
|
||||
var displayName: String {
|
||||
let trimmed = self.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "Untitled job" : trimmed
|
||||
trimmed.isEmpty ? "Untitled job" : trimmed
|
||||
}
|
||||
|
||||
var nextRunDate: Date? {
|
||||
|
||||
@@ -113,27 +113,29 @@ final class ExecApprovalsGatewayPrompter {
|
||||
let mode = AppStateStore.shared.connectionMode
|
||||
let activeSession = WebChatManager.shared.activeSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let requestSession = request.request.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
|
||||
// Read-only resolve to avoid disk writes on the MainActor
|
||||
let approvals = ExecApprovalsStore.resolveReadOnly(agentId: request.request.agentId)
|
||||
let security = approvals.agent.security
|
||||
let ask = approvals.agent.ask
|
||||
|
||||
|
||||
let shouldAsk = Self.shouldAsk(security: security, ask: ask)
|
||||
|
||||
|
||||
let canPresent = shouldAsk && Self.shouldPresent(
|
||||
mode: mode,
|
||||
activeSession: activeSession,
|
||||
requestSession: requestSession,
|
||||
lastInputSeconds: Self.lastInputSeconds(),
|
||||
thresholdSeconds: 120)
|
||||
|
||||
thresholdSeconds: 120
|
||||
)
|
||||
|
||||
return PresentationDecision(
|
||||
shouldAsk: shouldAsk,
|
||||
canPresent: canPresent,
|
||||
security: security,
|
||||
askFallback: approvals.agent.askFallback,
|
||||
allowlist: approvals.allowlist)
|
||||
allowlist: approvals.allowlist
|
||||
)
|
||||
}
|
||||
|
||||
private static func fallbackDecision(
|
||||
|
||||
@@ -147,7 +147,10 @@ actor MacNodeBrowserProxy {
|
||||
}
|
||||
|
||||
if method != "GET", let body = params.body {
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body.foundationValue, options: [.fragmentsAllowed])
|
||||
request.httpBody = try JSONSerialization.data(
|
||||
withJSONObject: body.foundationValue,
|
||||
options: [.fragmentsAllowed]
|
||||
)
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
}
|
||||
|
||||
|
||||
@@ -337,7 +337,6 @@ extension OnboardingView {
|
||||
self.remoteProbePreflightMessage == nil && self.remoteProbeState != .checking
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func remoteConnectionSection() -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
@@ -440,7 +439,7 @@ extension OnboardingView {
|
||||
|
||||
private func remoteAuthPromptView(issue: RemoteGatewayAuthIssue) -> some View {
|
||||
let promptStyle = Self.remoteAuthPromptStyle(for: issue)
|
||||
return HStack(alignment: .top, spacing: 10) {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: promptStyle.systemImage)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(promptStyle.tint)
|
||||
@@ -503,17 +502,17 @@ extension OnboardingView {
|
||||
{
|
||||
switch issue {
|
||||
case .tokenRequired:
|
||||
return ("key.fill", .orange)
|
||||
("key.fill", .orange)
|
||||
case .tokenMismatch:
|
||||
return ("exclamationmark.triangle.fill", .orange)
|
||||
("exclamationmark.triangle.fill", .orange)
|
||||
case .gatewayTokenNotConfigured:
|
||||
return ("wrench.and.screwdriver.fill", .orange)
|
||||
("wrench.and.screwdriver.fill", .orange)
|
||||
case .setupCodeExpired:
|
||||
return ("qrcode.viewfinder", .orange)
|
||||
("qrcode.viewfinder", .orange)
|
||||
case .passwordRequired:
|
||||
return ("lock.slash.fill", .orange)
|
||||
("lock.slash.fill", .orange)
|
||||
case .pairingRequired:
|
||||
return ("link.badge.plus", .orange)
|
||||
("link.badge.plus", .orange)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -155,7 +155,9 @@ enum RemoteGatewayProbe {
|
||||
return .failed("Set a gateway URL first")
|
||||
}
|
||||
guard self.isValidWsUrl(trimmedUrl) else {
|
||||
return .failed("Gateway URL must use wss:// for remote hosts (ws:// only for localhost)")
|
||||
return .failed(
|
||||
"Gateway URL must use wss:// for remote hosts (ws:// only for localhost)"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let trimmedTarget = settings.target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -173,7 +175,8 @@ enum RemoteGatewayProbe {
|
||||
command: sshCommand,
|
||||
cwd: nil,
|
||||
env: nil,
|
||||
timeout: 8)
|
||||
timeout: 8
|
||||
)
|
||||
guard sshResult.ok else {
|
||||
return .failed(self.formatSSHFailure(sshResult, target: settings.target))
|
||||
}
|
||||
@@ -221,7 +224,10 @@ enum RemoteGatewayProbe {
|
||||
trimmed.localizedCaseInsensitiveContains("host key verification failed")
|
||||
{
|
||||
let host = CommandResolver.parseSSHTarget(target)?.host ?? target
|
||||
return "SSH check failed: Host key verification failed. Remove the old key with ssh-keygen -R \(host) and try again."
|
||||
return """
|
||||
SSH check failed: Host key verification failed. Remove the old key with \
|
||||
ssh-keygen -R \(host) and try again.
|
||||
"""
|
||||
}
|
||||
if let trimmed, !trimmed.isEmpty {
|
||||
if let message = response.message, message.hasPrefix("exit ") {
|
||||
|
||||
@@ -26,12 +26,16 @@ enum TalkModeGatewayConfigParser {
|
||||
envApiKey: String?
|
||||
) -> TalkModeGatewayConfigState {
|
||||
let talk = snapshot.config?["talk"]?.dictionaryValue
|
||||
let selection = TalkConfigParsing.selectProviderConfig(talk, defaultProvider: defaultProvider)
|
||||
let selection = TalkConfigParsing.selectProviderConfig(
|
||||
talk,
|
||||
defaultProvider: defaultProvider
|
||||
)
|
||||
let activeProvider = selection?.provider ?? defaultProvider
|
||||
let activeConfig = selection?.config
|
||||
let silenceTimeoutMs = TalkConfigParsing.resolvedSilenceTimeoutMs(
|
||||
talk,
|
||||
fallback: defaultSilenceTimeoutMs)
|
||||
fallback: defaultSilenceTimeoutMs
|
||||
)
|
||||
let ui = snapshot.config?["ui"]?.dictionaryValue
|
||||
let rawSeam = ui?["seamColor"]?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let voice = activeConfig?["voiceId"]?.stringValue
|
||||
@@ -73,7 +77,8 @@ enum TalkModeGatewayConfigParser {
|
||||
interruptOnSpeech: interrupt ?? true,
|
||||
silenceTimeoutMs: silenceTimeoutMs,
|
||||
apiKey: resolvedApiKey,
|
||||
seamColorHex: rawSeam.isEmpty ? nil : rawSeam)
|
||||
seamColorHex: rawSeam.isEmpty ? nil : rawSeam
|
||||
)
|
||||
}
|
||||
|
||||
static func fallback(
|
||||
|
||||
@@ -4111,6 +4111,20 @@
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "agents.list.*.fastModeDefault",
|
||||
"kind": "core",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Agent Fast Mode Default",
|
||||
"help": "Optional per-agent default for fast mode. Applies when no per-message or session fast-mode override is set.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "agents.list.*.groupChat",
|
||||
"kind": "core",
|
||||
@@ -5226,6 +5240,25 @@
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "agents.list.*.reasoningDefault",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"enumValues": [
|
||||
"on",
|
||||
"off",
|
||||
"stream"
|
||||
],
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Agent Reasoning Default",
|
||||
"help": "Optional per-agent default reasoning visibility (on|off|stream). Applies when no per-message or session reasoning override is set.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "agents.list.*.runtime",
|
||||
"kind": "core",
|
||||
@@ -6287,6 +6320,29 @@
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "agents.list.*.thinkingDefault",
|
||||
"kind": "core",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"enumValues": [
|
||||
"off",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"adaptive"
|
||||
],
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Agent Thinking Default",
|
||||
"help": "Optional per-agent default thinking level. Overrides agents.defaults.thinkingDefault for this agent when no per-message or session override is set.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "agents.list.*.tools",
|
||||
"kind": "core",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5563}
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5566}
|
||||
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -354,6 +354,7 @@
|
||||
{"recordType":"path","path":"agents.list.*","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"agents.list.*.agentDir","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.default","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.fastModeDefault","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent Fast Mode Default","help":"Optional per-agent default for fast mode. Applies when no per-message or session fast-mode override is set.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.groupChat","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"agents.list.*.groupChat.historyLimit","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.groupChat.mentionPatterns","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@@ -463,6 +464,7 @@
|
||||
{"recordType":"path","path":"agents.list.*.name","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.params","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"agents.list.*.params.*","kind":"core","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.reasoningDefault","kind":"core","type":"string","required":false,"enumValues":["on","off","stream"],"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent Reasoning Default","help":"Optional per-agent default reasoning visibility (on|off|stream). Applies when no per-message or session reasoning override is set.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.runtime","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent Runtime","help":"Optional runtime descriptor for this agent. Use embedded for default OpenClaw execution or acp for external ACP harness defaults.","hasChildren":true}
|
||||
{"recordType":"path","path":"agents.list.*.runtime.acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent ACP Runtime","help":"ACP runtime defaults for this agent when runtime.type=acp. Binding-level ACP overrides still take precedence per conversation.","hasChildren":true}
|
||||
{"recordType":"path","path":"agents.list.*.runtime.acp.agent","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent ACP Harness Agent","help":"Optional ACP harness agent id to use for this OpenClaw agent (for example codex, claude).","hasChildren":false}
|
||||
@@ -561,6 +563,7 @@
|
||||
{"recordType":"path","path":"agents.list.*.subagents.model.fallbacks.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.subagents.model.primary","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.subagents.thinking","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.thinkingDefault","kind":"core","type":"string","required":false,"enumValues":["off","minimal","low","medium","high","xhigh","adaptive"],"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Agent Thinking Default","help":"Optional per-agent default thinking level. Overrides agents.defaults.thinkingDefault for this agent when no per-message or session override is set.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.tools","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"agents.list.*.tools.allow","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"agents.list.*.tools.allow.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
|
||||
@@ -30,7 +30,6 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`status`](/cli/status)
|
||||
- [`health`](/cli/health)
|
||||
- [`sessions`](/cli/sessions)
|
||||
- [`report`](/cli/report)
|
||||
- [`gateway`](/cli/gateway)
|
||||
- [`logs`](/cli/logs)
|
||||
- [`system`](/cli/system)
|
||||
@@ -152,10 +151,6 @@ openclaw [--dev] [--profile <name>] <command>
|
||||
status
|
||||
health
|
||||
sessions
|
||||
report
|
||||
bug
|
||||
feature
|
||||
security
|
||||
gateway
|
||||
call
|
||||
health
|
||||
@@ -717,27 +712,6 @@ Options:
|
||||
- `--store <path>`
|
||||
- `--active <minutes>`
|
||||
|
||||
### `report`
|
||||
|
||||
Prepare sanitized bug reports, feature requests, and private security report packets for `openclaw/openclaw`.
|
||||
|
||||
Subcommands:
|
||||
|
||||
- `bug`
|
||||
- `feature`
|
||||
- `security`
|
||||
|
||||
Shared options:
|
||||
|
||||
- `--title <text>`
|
||||
- `--summary <text>`
|
||||
- `--json`
|
||||
- `--markdown`
|
||||
- `--output <file>`
|
||||
- `--submit`
|
||||
- `--yes`
|
||||
- `--non-interactive`
|
||||
|
||||
## Reset / Uninstall
|
||||
|
||||
### `reset`
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
---
|
||||
summary: "CLI reference for `openclaw report` (bug reports, feature requests, and private security packets)"
|
||||
read_when:
|
||||
- You want to prepare a sanitized GitHub issue draft from local OpenClaw state
|
||||
- You want to submit a public bug or feature issue with `gh`
|
||||
- You need a private security report packet instead of a public issue
|
||||
title: "report"
|
||||
---
|
||||
|
||||
# `openclaw report`
|
||||
|
||||
Prepare sanitized reports for `openclaw/openclaw`.
|
||||
|
||||
`openclaw report` turns a small amount of user input plus local runtime/config context into:
|
||||
|
||||
- public bug report drafts
|
||||
- public feature request drafts
|
||||
- private security report packets
|
||||
|
||||
Public bug and feature reports can optionally be submitted with `gh`. Security reports never create a public GitHub issue.
|
||||
|
||||
## Subcommands
|
||||
|
||||
- `openclaw report bug`
|
||||
- `openclaw report feature`
|
||||
- `openclaw report security`
|
||||
|
||||
## Shared flags
|
||||
|
||||
- `--title <text>`: explicit report title
|
||||
- `--summary <text>`: short summary (used in public reports and as a fallback title)
|
||||
- `--json`: emit the structured sanitized payload
|
||||
- `--markdown`: emit only the rendered report body
|
||||
- `--output <file>`: write the sanitized body to a file
|
||||
- `--submit`: submit a public bug/feature issue when the report is ready
|
||||
- `--yes`: skip interactive confirmation for submission
|
||||
- `--non-interactive`: disable prompts; public submission requires `--yes`
|
||||
|
||||
If neither `--json` nor `--markdown` is passed, the default output is a human-readable sanitized preview.
|
||||
|
||||
`--yes` only skips the final interactive confirmation. It does not skip draft generation, diagnostics, or probe execution.
|
||||
|
||||
## Bug reports
|
||||
|
||||
Use `openclaw report bug` for broken behavior, regressions, or operational failures.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
openclaw report bug \
|
||||
--summary "Gateway times out behind mitmproxy" \
|
||||
--repro "1. Start gateway behind proxy\n2. Send any LLM request" \
|
||||
--expected "Model responds successfully" \
|
||||
--actual "Requests fail with timeout" \
|
||||
--impact "Blocks all LLM traffic"
|
||||
```
|
||||
|
||||
```bash
|
||||
openclaw report bug \
|
||||
--summary "Gateway times out behind mitmproxy" \
|
||||
--repro "1. Start gateway behind proxy\n2. Send any LLM request" \
|
||||
--expected "Model responds successfully" \
|
||||
--actual "Requests fail with timeout" \
|
||||
--impact "Blocks all LLM traffic" \
|
||||
--probe gateway \
|
||||
--submit
|
||||
```
|
||||
|
||||
Bug-specific flags:
|
||||
|
||||
- `--repro <text>`: steps to reproduce
|
||||
- `--expected <text>`: expected behavior
|
||||
- `--actual <text>`: observed behavior
|
||||
- `--impact <text>`: severity or workflow impact
|
||||
- `--previous-version <text>`: optional regression context
|
||||
- `--evidence <text>`: extra evidence to append
|
||||
- `--additional-information <text>`: broad extra details, clues, timelines, or hypotheses
|
||||
- `--context <text>`: compatibility alias for `--additional-information`
|
||||
- `--probe <general|model|channel|gateway|none>`: bounded evidence collection mode
|
||||
|
||||
Required fields for a submission-eligible bug report:
|
||||
|
||||
- summary
|
||||
- repro
|
||||
- expected
|
||||
- actual
|
||||
- impact
|
||||
|
||||
Auto-collected where available:
|
||||
|
||||
- OpenClaw version
|
||||
- OS/runtime summary
|
||||
- configured model/provider hints
|
||||
- a short bounded probe summary when `--probe` is enabled
|
||||
|
||||
Probe guidance:
|
||||
|
||||
- `general`: runtime summary, proxy env context, gateway/model/channel signals, and one recent sanitized runtime error when available
|
||||
- `gateway`: gateway reachability, health, and proxy context
|
||||
- `model`: provider auth overview plus a bounded live model-path check with combined proxy-status output
|
||||
- `channel`: configured-channel summary plus recent channel/runtime issue hints
|
||||
|
||||
## Feature requests
|
||||
|
||||
Use `openclaw report feature` for improvements or new capabilities.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
openclaw report feature \
|
||||
--summary "Add a report dry-run flag" \
|
||||
--problem "Operators want draft output without touching GitHub" \
|
||||
--solution "Support report --submit only when explicitly requested" \
|
||||
--impact "Safer issue authoring from scripts"
|
||||
```
|
||||
|
||||
Feature-specific flags:
|
||||
|
||||
- `--problem <text>`: problem to solve
|
||||
- `--solution <text>`: proposed solution
|
||||
- `--impact <text>`: expected impact
|
||||
- `--alternatives <text>`: alternatives considered
|
||||
- `--evidence <text>`: examples or supporting evidence
|
||||
- `--additional-information <text>`: broad extra details, clues, timelines, or hypotheses
|
||||
- `--context <text>`: compatibility alias for `--additional-information`
|
||||
- `--probe <general|model|channel|gateway|none>`: optional bounded evidence collection
|
||||
|
||||
Required fields for a submission-eligible feature request:
|
||||
|
||||
- summary
|
||||
- problem
|
||||
- solution
|
||||
- impact
|
||||
|
||||
## Security reports
|
||||
|
||||
Use `openclaw report security` for private vulnerability reports or sensitive disclosures.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
openclaw report security \
|
||||
--title "Gateway token exposed in logs" \
|
||||
--severity high \
|
||||
--impact "Operator credential disclosure" \
|
||||
--component "gateway auth logging" \
|
||||
--reproduction "Run startup flow with verbose logging enabled" \
|
||||
--demonstrated-impact "Token appears in terminal output" \
|
||||
--environment "macOS 15.4, OpenClaw 2026.3.x" \
|
||||
--remediation "Mask auth values before logging"
|
||||
```
|
||||
|
||||
Security-specific flags:
|
||||
|
||||
- `--severity <text>`
|
||||
- `--impact <text>`
|
||||
- `--component <text>`
|
||||
- `--reproduction <text>`
|
||||
- `--demonstrated-impact <text>`
|
||||
- `--environment <text>`
|
||||
- `--remediation <text>`
|
||||
|
||||
Rules:
|
||||
|
||||
- `report security` never calls `gh issue create`
|
||||
- `--submit` is ignored as a public-issue path and returns a blocked submission status
|
||||
- terminal output stays private-report-oriented
|
||||
- use `--output` or `--markdown` to save a private report packet for manual sending
|
||||
|
||||
Private route: send completed security reports to `security@openclaw.ai`.
|
||||
|
||||
## Redaction and submission behavior
|
||||
|
||||
The command sanitizes common sensitive values before rendering output or submitting:
|
||||
|
||||
- tokens / bearer values / API keys
|
||||
- email addresses
|
||||
- phone numbers
|
||||
- private user handles
|
||||
- local user path prefixes such as `/Users/<name>` or `/home/<name>`
|
||||
|
||||
For public bug and feature reports:
|
||||
|
||||
- `--submit` is required before any GitHub issue is created
|
||||
- interactive runs ask for confirmation before `gh issue create`
|
||||
- non-interactive submission requires both `--submit` and `--yes`
|
||||
- if required fields are missing, the command returns a structured blocked state instead of guessing
|
||||
- generated report bodies include a short provenance footer noting they were generated via `openclaw report`
|
||||
|
||||
## JSON output
|
||||
|
||||
`--json` emits a stable sanitized payload with fields such as:
|
||||
|
||||
- `kind`
|
||||
- `title`
|
||||
- `body`
|
||||
- `labels`
|
||||
- `evidence`
|
||||
- `redactionsApplied`
|
||||
- `missingFields`
|
||||
- `submissionEligible`
|
||||
- `submission`
|
||||
|
||||
This is intended for scripting and higher-level automation.
|
||||
@@ -303,6 +303,20 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
|
||||
},
|
||||
},
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
default: true,
|
||||
thinkingDefault: "high", // per-agent thinking override
|
||||
reasoningDefault: "on", // per-agent reasoning visibility
|
||||
fastModeDefault: false, // per-agent fast mode
|
||||
},
|
||||
{
|
||||
id: "quick",
|
||||
fastModeDefault: true, // this agent always runs fast
|
||||
thinkingDefault: "off",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
tools: {
|
||||
|
||||
@@ -1372,6 +1372,9 @@ scripts/sandbox-browser-setup.sh # optional browser image
|
||||
workspace: "~/.openclaw/workspace",
|
||||
agentDir: "~/.openclaw/agents/main/agent",
|
||||
model: "anthropic/claude-opus-4-6", // or { primary, fallbacks }
|
||||
thinkingDefault: "high", // per-agent thinking level override
|
||||
reasoningDefault: "on", // per-agent reasoning visibility override
|
||||
fastModeDefault: false, // per-agent fast mode override
|
||||
params: { cacheRetention: "none" }, // overrides matching defaults.models params by key
|
||||
identity: {
|
||||
name: "Samantha",
|
||||
@@ -1407,6 +1410,9 @@ scripts/sandbox-browser-setup.sh # optional browser image
|
||||
- `default`: when multiple are set, first wins (warning logged). If none set, first list entry is default.
|
||||
- `model`: string form overrides `primary` only; object form `{ primary, fallbacks }` overrides both (`[]` disables global fallbacks). Cron jobs that only override `primary` still inherit default fallbacks unless you set `fallbacks: []`.
|
||||
- `params`: per-agent stream params merged over the selected model entry in `agents.defaults.models`. Use this for agent-specific overrides like `cacheRetention`, `temperature`, or `maxTokens` without duplicating the whole model catalog.
|
||||
- `thinkingDefault`: optional per-agent default thinking level (`off | minimal | low | medium | high | xhigh | adaptive`). Overrides `agents.defaults.thinkingDefault` for this agent when no per-message or session override is set.
|
||||
- `reasoningDefault`: optional per-agent default reasoning visibility (`on | off | stream`). Applies when no per-message or session reasoning override is set.
|
||||
- `fastModeDefault`: optional per-agent default for fast mode (`true | false`). Applies when no per-message or session fast-mode override is set.
|
||||
- `runtime`: optional per-agent runtime descriptor. Use `type: "acp"` with `runtime.acp` defaults (`agent`, `backend`, `mode`, `cwd`) when the agent should default to ACP harness sessions.
|
||||
- `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI.
|
||||
- `identity` derives defaults: `ackReaction` from `emoji`, `mentionPatterns` from `name`/`emoji`.
|
||||
|
||||
@@ -133,6 +133,29 @@ When set, `OPENCLAW_HOME` replaces the system home directory (`$HOME` / `os.home
|
||||
|
||||
`OPENCLAW_HOME` can also be set to a tilde path (e.g. `~/svc`), which gets expanded using `$HOME` before use.
|
||||
|
||||
## nvm users: web_fetch TLS failures
|
||||
|
||||
If Node.js was installed via **nvm** (not the system package manager), the built-in `fetch()` uses
|
||||
nvm's bundled CA store, which may be missing modern root CAs (ISRG Root X1/X2 for Let's Encrypt,
|
||||
DigiCert Global Root G2, etc.). This causes `web_fetch` to fail with `"fetch failed"` on most HTTPS sites.
|
||||
|
||||
On Linux, OpenClaw automatically detects nvm and applies the fix in the actual startup environment:
|
||||
|
||||
- `openclaw gateway install` writes `NODE_EXTRA_CA_CERTS` into the systemd service environment
|
||||
- the `openclaw` CLI entrypoint re-execs itself with `NODE_EXTRA_CA_CERTS` set before Node startup
|
||||
|
||||
**Manual fix (for older versions or direct `node ...` launches):**
|
||||
|
||||
Export the variable before starting OpenClaw:
|
||||
|
||||
```bash
|
||||
export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt
|
||||
openclaw gateway run
|
||||
```
|
||||
|
||||
Do not rely on writing only to `~/.openclaw/.env` for this variable; Node reads
|
||||
`NODE_EXTRA_CA_CERTS` at process startup.
|
||||
|
||||
## Related
|
||||
|
||||
- [Gateway configuration](/gateway/configuration)
|
||||
|
||||
@@ -128,7 +128,7 @@ my-plugin/
|
||||
**Provider plugin:**
|
||||
|
||||
```typescript
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "my-provider",
|
||||
@@ -144,7 +144,7 @@ my-plugin/
|
||||
**Multi-capability plugin** (provider + tool):
|
||||
|
||||
```typescript
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "my-plugin",
|
||||
@@ -157,8 +157,14 @@ my-plugin/
|
||||
});
|
||||
```
|
||||
|
||||
Use `defineChannelPluginEntry` for channel plugins and `definePluginEntry`
|
||||
for everything else. A single plugin can register as many capabilities as needed.
|
||||
Use `defineChannelPluginEntry` from `plugin-sdk/core` for channel plugins
|
||||
and `definePluginEntry` from `plugin-sdk/plugin-entry` for everything else.
|
||||
A single plugin can register as many capabilities as needed.
|
||||
|
||||
For chat-style channels, `plugin-sdk/core` also exposes
|
||||
`createChatChannelPlugin(...)` so you can compose common DM security,
|
||||
text pairing, reply threading, and attached outbound send results without
|
||||
wiring each adapter separately.
|
||||
|
||||
</Step>
|
||||
|
||||
@@ -173,7 +179,7 @@ my-plugin/
|
||||
|
||||
```typescript
|
||||
// Correct: focused subpaths
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth";
|
||||
|
||||
@@ -187,7 +193,8 @@ my-plugin/
|
||||
<Accordion title="Common subpaths reference">
|
||||
| Subpath | Purpose |
|
||||
| --- | --- |
|
||||
| `plugin-sdk/core` | Plugin entry definitions and base types |
|
||||
| `plugin-sdk/plugin-entry` | Canonical `definePluginEntry` helper + provider/plugin entry types |
|
||||
| `plugin-sdk/core` | Channel entry helpers, channel builders, and shared base types |
|
||||
| `plugin-sdk/channel-setup` | Setup wizard adapters |
|
||||
| `plugin-sdk/channel-pairing` | DM pairing primitives |
|
||||
| `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring |
|
||||
|
||||
@@ -115,7 +115,8 @@ is a small, self-contained module with a clear purpose and documented contract.
|
||||
<Accordion title="Full import path table">
|
||||
| Import path | Purpose | Key exports |
|
||||
| --- | --- | --- |
|
||||
| `plugin-sdk/core` | Plugin entry definitions, base types | `defineChannelPluginEntry`, `definePluginEntry` |
|
||||
| `plugin-sdk/plugin-entry` | Canonical plugin entry helper | `definePluginEntry` |
|
||||
| `plugin-sdk/core` | Channel entry definitions, channel builders, base types | `defineChannelPluginEntry`, `createChatChannelPlugin` |
|
||||
| `plugin-sdk/channel-setup` | Setup wizard adapters | `createOptionalChannelSetupSurface` |
|
||||
| `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` |
|
||||
| `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | `createChannelReplyPipeline` |
|
||||
|
||||
@@ -28,8 +28,9 @@ title: "Thinking Levels"
|
||||
|
||||
1. Inline directive on the message (applies only to that message).
|
||||
2. Session override (set by sending a directive-only message).
|
||||
3. Global default (`agents.defaults.thinkingDefault` in config).
|
||||
4. Fallback: `adaptive` for Anthropic Claude 4.6 models, `low` for other reasoning-capable models, `off` otherwise.
|
||||
3. Per-agent default (`agents.list[].thinkingDefault` in config).
|
||||
4. Global default (`agents.defaults.thinkingDefault` in config).
|
||||
5. Fallback: `adaptive` for Anthropic Claude 4.6 models, `low` for other reasoning-capable models, `off` otherwise.
|
||||
|
||||
## Setting a session default
|
||||
|
||||
@@ -50,8 +51,9 @@ title: "Thinking Levels"
|
||||
- OpenClaw resolves fast mode in this order:
|
||||
1. Inline/directive-only `/fast on|off`
|
||||
2. Session override
|
||||
3. Per-model config: `agents.defaults.models["<provider>/<model>"].params.fastMode`
|
||||
4. Fallback: `off`
|
||||
3. Per-agent default (`agents.list[].fastModeDefault`)
|
||||
4. Per-model config: `agents.defaults.models["<provider>/<model>"].params.fastMode`
|
||||
5. Fallback: `off`
|
||||
- For `openai/*`, fast mode applies the OpenAI fast profile: `service_tier=priority` when supported, plus low reasoning effort and low text verbosity.
|
||||
- For `openai-codex/*`, fast mode applies the same low-latency profile on Codex Responses. OpenClaw keeps one shared `/fast` toggle across both auth paths.
|
||||
- For direct `anthropic/*` API-key requests, fast mode maps to Anthropic service tiers: `/fast on` sets `service_tier=auto`, `/fast off` sets `service_tier=standard_only`.
|
||||
@@ -76,6 +78,7 @@ title: "Thinking Levels"
|
||||
- `stream` (Telegram only): streams reasoning into the Telegram draft bubble while the reply is generating, then sends the final answer without reasoning.
|
||||
- Alias: `/reason`.
|
||||
- Send `/reasoning` (or `/reasoning:`) with no argument to see the current reasoning level.
|
||||
- Resolution order: inline directive, then session override, then per-agent default (`agents.list[].reasoningDefault`), then fallback (`off`).
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -390,9 +390,9 @@ Notes:
|
||||
|
||||
## Agent tool
|
||||
|
||||
The `tts` tool converts text to speech and returns a `MEDIA:` path. When the
|
||||
result is Telegram-compatible, the tool includes `[[audio_as_voice]]` so
|
||||
Telegram sends a voice bubble.
|
||||
The `tts` tool converts text to speech and returns an audio attachment for
|
||||
reply delivery. When the result is Telegram-compatible, OpenClaw marks it for
|
||||
voice-bubble delivery.
|
||||
|
||||
## Gateway RPC
|
||||
|
||||
|
||||
@@ -390,9 +390,9 @@ Notes:
|
||||
|
||||
## Agent tool
|
||||
|
||||
The `tts` tool converts text to speech and returns a `MEDIA:` path. When the
|
||||
result is Telegram-compatible, the tool includes `[[audio_as_voice]]` so
|
||||
Telegram sends a voice bubble.
|
||||
The `tts` tool converts text to speech and returns an audio attachment for
|
||||
reply delivery. When the result is Telegram-compatible, OpenClaw marks it for
|
||||
voice-bubble delivery.
|
||||
|
||||
## Gateway RPC
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ x-i18n:
|
||||
- [`status`](/cli/status)
|
||||
- [`health`](/cli/health)
|
||||
- [`sessions`](/cli/sessions)
|
||||
- [`report`](/cli/report)
|
||||
- [`gateway`](/cli/gateway)
|
||||
- [`logs`](/cli/logs)
|
||||
- [`system`](/cli/system)
|
||||
@@ -157,10 +156,6 @@ openclaw [--dev] [--profile <name>] <command>
|
||||
status
|
||||
health
|
||||
sessions
|
||||
report
|
||||
bug
|
||||
feature
|
||||
security
|
||||
gateway
|
||||
call
|
||||
health
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
---
|
||||
read_when:
|
||||
- 你想基于本地 OpenClaw 状态生成脱敏后的 GitHub issue 草稿
|
||||
- 你想用 `gh` 提交公开的 bug 或功能请求
|
||||
- 你需要私下发送安全报告而不是创建公开 issue
|
||||
summary: "`openclaw report` 的 CLI 参考(bug 报告、功能请求和私有安全报告包)"
|
||||
title: report
|
||||
x-i18n:
|
||||
generated_at: "2026-03-21T03:20:00Z"
|
||||
model: gpt-5.4
|
||||
provider: openai
|
||||
source_path: cli/report.md
|
||||
workflow: 15
|
||||
---
|
||||
|
||||
# `openclaw report`
|
||||
|
||||
为 `openclaw/openclaw` 准备脱敏后的报告。
|
||||
|
||||
`openclaw report` 会把少量用户输入与本地运行时/配置上下文组合起来,生成:
|
||||
|
||||
- 公开 bug 报告草稿
|
||||
- 公开功能请求草稿
|
||||
- 私有安全报告包
|
||||
|
||||
公开的 bug 和功能请求可以选择用 `gh` 提交。安全报告永远不会创建公开 GitHub issue。
|
||||
|
||||
## 子命令
|
||||
|
||||
- `openclaw report bug`
|
||||
- `openclaw report feature`
|
||||
- `openclaw report security`
|
||||
|
||||
## 共享标志
|
||||
|
||||
- `--title <text>`:显式指定报告标题
|
||||
- `--summary <text>`:简短摘要(公开报告中使用,也可作为回退标题)
|
||||
- `--json`:输出结构化的脱敏 payload
|
||||
- `--markdown`:仅输出渲染后的报告正文
|
||||
- `--output <file>`:将脱敏后的正文写入文件
|
||||
- `--submit`:在报告完整时提交公开 bug/feature issue
|
||||
- `--yes`:跳过提交前的交互确认
|
||||
- `--non-interactive`:禁用提示;公开提交时需要同时传 `--yes`
|
||||
|
||||
如果既没有传 `--json` 也没有传 `--markdown`,默认输出为人类可读的脱敏预览。
|
||||
|
||||
`--yes` 只会跳过最后的交互确认,不会跳过草稿生成、诊断收集或 probe 执行。
|
||||
|
||||
## Bug 报告
|
||||
|
||||
`openclaw report bug` 用于故障行为、回归问题或运行失败。
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
openclaw report bug \
|
||||
--summary "Gateway 在 mitmproxy 后超时" \
|
||||
--repro "1. 在代理后启动 gateway\n2. 发送任意 LLM 请求" \
|
||||
--expected "模型成功响应" \
|
||||
--actual "请求因超时失败" \
|
||||
--impact "阻塞所有 LLM 流量"
|
||||
```
|
||||
|
||||
```bash
|
||||
openclaw report bug \
|
||||
--summary "Gateway 在 mitmproxy 后超时" \
|
||||
--repro "1. 在代理后启动 gateway\n2. 发送任意 LLM 请求" \
|
||||
--expected "模型成功响应" \
|
||||
--actual "请求因超时失败" \
|
||||
--impact "阻塞所有 LLM 流量" \
|
||||
--probe gateway \
|
||||
--submit
|
||||
```
|
||||
|
||||
Bug 专用标志:
|
||||
|
||||
- `--repro <text>`:复现步骤
|
||||
- `--expected <text>`:期望行为
|
||||
- `--actual <text>`:实际行为
|
||||
- `--impact <text>`:对用户或运维的影响
|
||||
- `--previous-version <text>`:可选的回归版本上下文
|
||||
- `--evidence <text>`:额外证据
|
||||
- `--additional-information <text>`:更宽泛的附加细节、线索、时间线或假设
|
||||
- `--context <text>`:`--additional-information` 的兼容别名
|
||||
- `--probe <general|model|channel|gateway|none>`:有限的证据采集模式
|
||||
|
||||
要达到可提交的 bug 报告状态,至少需要:
|
||||
|
||||
- `summary`
|
||||
- `repro`
|
||||
- `expected`
|
||||
- `actual`
|
||||
- `impact`
|
||||
|
||||
如果可用,还会自动收集:
|
||||
|
||||
- OpenClaw 版本
|
||||
- OS / 运行时摘要
|
||||
- 已配置的 model / provider 线索
|
||||
- 在启用 `--probe` 时生成的简短探测摘要
|
||||
|
||||
Probe 说明:
|
||||
|
||||
- `general`:运行时摘要、代理环境上下文、gateway/model/channel 信号,以及可用时的一条近期脱敏错误摘要
|
||||
- `gateway`:gateway 可达性、health 和代理上下文
|
||||
- `model`:provider 认证概览,以及带有代理状态组合输出的有限 live model-path 检查
|
||||
- `channel`:已配置 channel 摘要,以及近期 channel / runtime 问题线索
|
||||
|
||||
## 功能请求
|
||||
|
||||
`openclaw report feature` 用于产品改进或新能力需求。
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
openclaw report feature \
|
||||
--summary "添加 report dry-run 标志" \
|
||||
--problem "运维希望生成草稿而不直接触发 GitHub" \
|
||||
--solution "只有显式传 --submit 时才允许真正提交" \
|
||||
--impact "让脚本化 issue 编写更安全"
|
||||
```
|
||||
|
||||
Feature 专用标志:
|
||||
|
||||
- `--problem <text>`:要解决的问题
|
||||
- `--solution <text>`:提议的解决方案
|
||||
- `--impact <text>`:预期影响
|
||||
- `--alternatives <text>`:考虑过的替代方案
|
||||
- `--evidence <text>`:支持证据或示例
|
||||
- `--additional-information <text>`:更宽泛的附加细节、线索、时间线或假设
|
||||
- `--context <text>`:`--additional-information` 的兼容别名
|
||||
- `--probe <general|model|channel|gateway|none>`:可选的有限证据采集
|
||||
|
||||
要达到可提交的功能请求状态,至少需要:
|
||||
|
||||
- `summary`
|
||||
- `problem`
|
||||
- `solution`
|
||||
- `impact`
|
||||
|
||||
## 安全报告
|
||||
|
||||
`openclaw report security` 用于私下提交漏洞或敏感披露。
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
openclaw report security \
|
||||
--title "Gateway token 出现在日志中" \
|
||||
--severity high \
|
||||
--impact "运维凭证泄露" \
|
||||
--component "gateway auth logging" \
|
||||
--reproduction "启用 verbose logging 并运行启动流程" \
|
||||
--demonstrated-impact "token 出现在终端输出中" \
|
||||
--environment "macOS 15.4, OpenClaw 2026.3.x" \
|
||||
--remediation "在日志输出前先 mask 授权值"
|
||||
```
|
||||
|
||||
Security 专用标志:
|
||||
|
||||
- `--severity <text>`
|
||||
- `--impact <text>`
|
||||
- `--component <text>`
|
||||
- `--reproduction <text>`
|
||||
- `--demonstrated-impact <text>`
|
||||
- `--environment <text>`
|
||||
- `--remediation <text>`
|
||||
|
||||
规则:
|
||||
|
||||
- `report security` 永远不会调用 `gh issue create`
|
||||
- `--submit` 不会触发公开 issue 创建,而是返回被阻止的提交状态
|
||||
- 终端输出会保持为适合私有报告的内容
|
||||
- 可使用 `--output` 或 `--markdown` 保存私有报告包,后续手动发送
|
||||
|
||||
私有提交路径:将完整安全报告发送到 `security@openclaw.ai`。
|
||||
|
||||
## 脱敏与提交流程
|
||||
|
||||
该命令会在渲染输出或提交前自动脱敏常见敏感值:
|
||||
|
||||
- token / bearer 值 / API key
|
||||
- 邮箱地址
|
||||
- 电话号码
|
||||
- 私人用户句柄
|
||||
- 本地用户路径前缀,例如 `/Users/<name>` 或 `/home/<name>`
|
||||
|
||||
对于公开的 bug 和 feature 报告:
|
||||
|
||||
- 只有显式传 `--submit` 才会创建 GitHub issue
|
||||
- 交互式运行时会在 `gh issue create` 前要求确认
|
||||
- 非交互式提交需要同时传 `--submit` 和 `--yes`
|
||||
- 如果缺少必填字段,命令会返回结构化的阻止状态,而不是猜测内容
|
||||
- 生成后的报告正文会附带一条简短来源说明,表明该草稿由 `openclaw report` 生成
|
||||
|
||||
## JSON 输出
|
||||
|
||||
`--json` 会输出稳定的脱敏 payload,字段包括:
|
||||
|
||||
- `kind`
|
||||
- `title`
|
||||
- `body`
|
||||
- `labels`
|
||||
- `evidence`
|
||||
- `redactionsApplied`
|
||||
- `missingFields`
|
||||
- `submissionEligible`
|
||||
- `submission`
|
||||
|
||||
该输出适合脚本和更高层的自动化流程。
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type ProviderAuthContext,
|
||||
type ProviderResolveDynamicModelContext,
|
||||
type ProviderRuntimeModel,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
CLAUDE_CLI_PROFILE_ID,
|
||||
applyAuthProfileConfig,
|
||||
|
||||
@@ -6,6 +6,17 @@
|
||||
"dependencies": {
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.14"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,32 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
|
||||
import {
|
||||
createBlueBubblesMonitorTestRuntime,
|
||||
EMPTY_DISPATCH_RESULT,
|
||||
resetBlueBubblesMonitorTestState,
|
||||
type DispatchReplyParams,
|
||||
} from "../../../test/helpers/extensions/bluebubbles-monitor.js";
|
||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
import { fetchBlueBubblesHistory } from "./history.js";
|
||||
import { handleBlueBubblesWebhookRequest, resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
import {
|
||||
handleBlueBubblesWebhookRequest,
|
||||
registerBlueBubblesWebhookTarget,
|
||||
resolveBlueBubblesMessageId,
|
||||
_resetBlueBubblesShortIdState,
|
||||
} from "./monitor.js";
|
||||
LOOPBACK_REMOTE_ADDRESSES_FOR_TEST,
|
||||
createWebhookDispatchForTest,
|
||||
createMockAccount,
|
||||
createHangingWebhookRequestForTest,
|
||||
createLoopbackWebhookRequestParamsForTest,
|
||||
createPasswordQueryRequestParamsForTest,
|
||||
createProtectedWebhookAccountForTest,
|
||||
createRemoteWebhookRequestParamsForTest,
|
||||
createTimestampedNewMessagePayloadForTest,
|
||||
dispatchWebhookPayloadForTest,
|
||||
expectWebhookRequestStatusForTest,
|
||||
expectWebhookStatusForTest,
|
||||
setupWebhookTargetForTest,
|
||||
setupWebhookTargetsForTest,
|
||||
trackWebhookRegistrationForTest,
|
||||
type WebhookRequestParams,
|
||||
} from "./monitor.webhook.test-helpers.js";
|
||||
import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js";
|
||||
import { setBlueBubblesRuntime } from "./runtime.js";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("./send.js", () => ({
|
||||
@@ -70,13 +85,6 @@ const mockMatchesMentionWithExplicit = vi.fn(
|
||||
);
|
||||
const mockResolveRequireMention = vi.fn(() => false);
|
||||
const mockResolveGroupPolicy = vi.fn(() => "open" as const);
|
||||
type DispatchReplyParams = Parameters<
|
||||
PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"]
|
||||
>[0];
|
||||
const EMPTY_DISPATCH_RESULT = {
|
||||
queuedFinal: false,
|
||||
counts: { tool: 0, block: 0, final: 0 },
|
||||
} as const;
|
||||
const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(
|
||||
async (_params: DispatchReplyParams) => EMPTY_DISPATCH_RESULT,
|
||||
);
|
||||
@@ -99,162 +107,51 @@ const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
||||
const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
||||
const mockResolveChunkMode = vi.fn(() => "length" as const);
|
||||
const mockFetchBlueBubblesHistory = vi.mocked(fetchBlueBubblesHistory);
|
||||
const TEST_WEBHOOK_PASSWORD = "secret-token";
|
||||
|
||||
function createMockRuntime(): PluginRuntime {
|
||||
return createPluginRuntimeMock({
|
||||
system: {
|
||||
enqueueSystemEvent: mockEnqueueSystemEvent,
|
||||
},
|
||||
channel: {
|
||||
text: {
|
||||
chunkMarkdownText: mockChunkMarkdownText,
|
||||
chunkByNewline: mockChunkByNewline,
|
||||
chunkMarkdownTextWithMode: mockChunkMarkdownTextWithMode,
|
||||
chunkTextWithMode: mockChunkTextWithMode,
|
||||
resolveChunkMode:
|
||||
mockResolveChunkMode as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
|
||||
hasControlCommand: mockHasControlCommand,
|
||||
},
|
||||
reply: {
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
|
||||
formatAgentEnvelope: mockFormatAgentEnvelope,
|
||||
formatInboundEnvelope: mockFormatInboundEnvelope,
|
||||
resolveEnvelopeFormatOptions:
|
||||
mockResolveEnvelopeFormatOptions as unknown as PluginRuntime["channel"]["reply"]["resolveEnvelopeFormatOptions"],
|
||||
},
|
||||
routing: {
|
||||
resolveAgentRoute:
|
||||
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
|
||||
},
|
||||
pairing: {
|
||||
buildPairingReply: mockBuildPairingReply,
|
||||
readAllowFromStore: mockReadAllowFromStore,
|
||||
upsertPairingRequest: mockUpsertPairingRequest,
|
||||
},
|
||||
media: {
|
||||
saveMediaBuffer:
|
||||
mockSaveMediaBuffer as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"],
|
||||
},
|
||||
session: {
|
||||
resolveStorePath: mockResolveStorePath,
|
||||
readSessionUpdatedAt: mockReadSessionUpdatedAt,
|
||||
},
|
||||
mentions: {
|
||||
buildMentionRegexes: mockBuildMentionRegexes,
|
||||
matchesMentionPatterns: mockMatchesMentionPatterns,
|
||||
matchesMentionWithExplicit: mockMatchesMentionWithExplicit,
|
||||
},
|
||||
groups: {
|
||||
resolveGroupPolicy:
|
||||
mockResolveGroupPolicy as unknown as PluginRuntime["channel"]["groups"]["resolveGroupPolicy"],
|
||||
resolveRequireMention: mockResolveRequireMention,
|
||||
},
|
||||
commands: {
|
||||
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
|
||||
},
|
||||
},
|
||||
return createBlueBubblesMonitorTestRuntime({
|
||||
enqueueSystemEvent: mockEnqueueSystemEvent,
|
||||
chunkMarkdownText: mockChunkMarkdownText,
|
||||
chunkByNewline: mockChunkByNewline,
|
||||
chunkMarkdownTextWithMode: mockChunkMarkdownTextWithMode,
|
||||
chunkTextWithMode: mockChunkTextWithMode,
|
||||
resolveChunkMode: mockResolveChunkMode,
|
||||
hasControlCommand: mockHasControlCommand,
|
||||
dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher,
|
||||
formatAgentEnvelope: mockFormatAgentEnvelope,
|
||||
formatInboundEnvelope: mockFormatInboundEnvelope,
|
||||
resolveEnvelopeFormatOptions: mockResolveEnvelopeFormatOptions,
|
||||
resolveAgentRoute: mockResolveAgentRoute,
|
||||
buildPairingReply: mockBuildPairingReply,
|
||||
readAllowFromStore: mockReadAllowFromStore,
|
||||
upsertPairingRequest: mockUpsertPairingRequest,
|
||||
saveMediaBuffer: mockSaveMediaBuffer,
|
||||
resolveStorePath: mockResolveStorePath,
|
||||
readSessionUpdatedAt: mockReadSessionUpdatedAt,
|
||||
buildMentionRegexes: mockBuildMentionRegexes,
|
||||
matchesMentionPatterns: mockMatchesMentionPatterns,
|
||||
matchesMentionWithExplicit: mockMatchesMentionWithExplicit,
|
||||
resolveGroupPolicy: mockResolveGroupPolicy,
|
||||
resolveRequireMention: mockResolveRequireMention,
|
||||
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers,
|
||||
});
|
||||
}
|
||||
|
||||
function createMockAccount(
|
||||
overrides: Partial<ResolvedBlueBubblesAccount["config"]> = {},
|
||||
): ResolvedBlueBubblesAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
config: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password", // pragma: allowlist secret
|
||||
dmPolicy: "open",
|
||||
groupPolicy: "open",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMockRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
): IncomingMessage {
|
||||
if (headers.host === undefined) {
|
||||
headers.host = "localhost";
|
||||
}
|
||||
const parsedUrl = new URL(url, "http://localhost");
|
||||
const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password");
|
||||
const hasAuthHeader =
|
||||
headers["x-guid"] !== undefined ||
|
||||
headers["x-password"] !== undefined ||
|
||||
headers["x-bluebubbles-guid"] !== undefined ||
|
||||
headers.authorization !== undefined;
|
||||
if (!hasAuthQuery && !hasAuthHeader) {
|
||||
parsedUrl.searchParams.set("password", "test-password");
|
||||
}
|
||||
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
req.method = method;
|
||||
req.url = `${parsedUrl.pathname}${parsedUrl.search}`;
|
||||
req.headers = headers;
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress: "127.0.0.1" };
|
||||
|
||||
// Emit body data after a microtask
|
||||
// oxlint-disable-next-line no-floating-promises
|
||||
Promise.resolve().then(() => {
|
||||
const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
|
||||
req.emit("data", Buffer.from(bodyStr));
|
||||
req.emit("end");
|
||||
});
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
function createMockResponse(): ServerResponse & { body: string; statusCode: number } {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: "",
|
||||
setHeader: vi.fn(),
|
||||
end: vi.fn((data?: string) => {
|
||||
res.body = data ?? "";
|
||||
}),
|
||||
} as unknown as ServerResponse & { body: string; statusCode: number };
|
||||
return res;
|
||||
}
|
||||
|
||||
const flushAsync = async () => {
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
};
|
||||
|
||||
function getFirstDispatchCall(): DispatchReplyParams {
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0]?.[0];
|
||||
if (!callArgs) {
|
||||
throw new Error("expected dispatch call arguments");
|
||||
}
|
||||
return callArgs;
|
||||
}
|
||||
|
||||
describe("BlueBubbles webhook monitor", () => {
|
||||
let unregister: () => void;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset short ID state between tests for predictable behavior
|
||||
_resetBlueBubblesShortIdState();
|
||||
mockFetchBlueBubblesHistory.mockResolvedValue({ entries: [], resolved: true });
|
||||
mockReadAllowFromStore.mockResolvedValue([]);
|
||||
mockUpsertPairingRequest.mockResolvedValue({ code: "TESTCODE", created: true });
|
||||
mockResolveRequireMention.mockReturnValue(false);
|
||||
mockHasControlCommand.mockReturnValue(false);
|
||||
mockResolveCommandAuthorizedFromAuthorizers.mockReturnValue(false);
|
||||
mockBuildMentionRegexes.mockReturnValue([/\bbert\b/i]);
|
||||
|
||||
setBlueBubblesRuntime(createMockRuntime());
|
||||
resetBlueBubblesMonitorTestState({
|
||||
createRuntime: createMockRuntime,
|
||||
fetchHistoryMock: mockFetchBlueBubblesHistory,
|
||||
readAllowFromStoreMock: mockReadAllowFromStore,
|
||||
upsertPairingRequestMock: mockUpsertPairingRequest,
|
||||
resolveRequireMentionMock: mockResolveRequireMention,
|
||||
hasControlCommandMock: mockHasControlCommand,
|
||||
resolveCommandAuthorizedFromAuthorizersMock: mockResolveCommandAuthorizedFromAuthorizers,
|
||||
buildMentionRegexesMock: mockBuildMentionRegexes,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -267,75 +164,145 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
core?: PluginRuntime;
|
||||
statusSink?: (event: unknown) => void;
|
||||
}) {
|
||||
const account = params?.account ?? createMockAccount();
|
||||
const config = params?.config ?? {};
|
||||
const core = params?.core ?? createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
statusSink: params?.statusSink,
|
||||
});
|
||||
return { account, config, core };
|
||||
}
|
||||
|
||||
function createNewMessagePayload(dataOverrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
...dataOverrides,
|
||||
const registration = trackWebhookRegistrationForTest(
|
||||
setupWebhookTargetForTest({
|
||||
createCore: createMockRuntime,
|
||||
core: params?.core,
|
||||
account: params?.account,
|
||||
config: params?.config,
|
||||
statusSink: params?.statusSink,
|
||||
}),
|
||||
(nextUnregister) => {
|
||||
unregister = nextUnregister;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setRequestRemoteAddress(req: IncomingMessage, remoteAddress: string) {
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress,
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchWebhook(req: IncomingMessage) {
|
||||
const res = createMockResponse();
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
return { handled, res };
|
||||
}
|
||||
|
||||
function createWebhookRequestForTest(params?: {
|
||||
method?: string;
|
||||
url?: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
remoteAddress?: string;
|
||||
}) {
|
||||
const req = createMockRequest(
|
||||
params?.method ?? "POST",
|
||||
params?.url ?? "/bluebubbles-webhook",
|
||||
params?.body ?? {},
|
||||
params?.headers,
|
||||
);
|
||||
if (params?.remoteAddress) {
|
||||
setRequestRemoteAddress(req, params.remoteAddress);
|
||||
}
|
||||
return req;
|
||||
return {
|
||||
account: registration.account,
|
||||
config: registration.config,
|
||||
core: registration.core,
|
||||
};
|
||||
}
|
||||
|
||||
function createHangingWebhookRequest(url = "/bluebubbles-webhook?password=test-password") {
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
const destroyMock = vi.fn();
|
||||
req.method = "POST";
|
||||
req.url = url;
|
||||
req.headers = {};
|
||||
req.destroy = destroyMock as unknown as IncomingMessage["destroy"];
|
||||
setRequestRemoteAddress(req, "127.0.0.1");
|
||||
return { req, destroyMock };
|
||||
function setupProtectedWebhookTarget(password = TEST_WEBHOOK_PASSWORD) {
|
||||
return setupWebhookTargetAccount(createProtectedWebhookTarget(password).account);
|
||||
}
|
||||
|
||||
function setupPasswordlessWebhookTarget() {
|
||||
return setupWebhookTargetAccount(createPasswordlessWebhookTarget().account);
|
||||
}
|
||||
|
||||
function setupWebhookTargetAccount(account: ResolvedBlueBubblesAccount) {
|
||||
setupWebhookTarget({ account });
|
||||
return account;
|
||||
}
|
||||
|
||||
function createWebhookTarget(
|
||||
account: ResolvedBlueBubblesAccount,
|
||||
statusSink: (event: unknown) => void = vi.fn(),
|
||||
) {
|
||||
return { account, statusSink };
|
||||
}
|
||||
|
||||
function createProtectedWebhookTarget(password = TEST_WEBHOOK_PASSWORD) {
|
||||
return createWebhookTarget(createProtectedWebhookAccountForTest(password));
|
||||
}
|
||||
|
||||
function createPasswordlessWebhookTarget() {
|
||||
return createWebhookTarget(createMockAccount({ password: undefined }));
|
||||
}
|
||||
|
||||
function createProtectedPasswordQueryRequestParams(password = TEST_WEBHOOK_PASSWORD) {
|
||||
return createPasswordQueryRequestParamsForTest({ password });
|
||||
}
|
||||
|
||||
async function expectWebhookRequestStatusWithSetup(
|
||||
setup: () => void,
|
||||
params: WebhookRequestParams,
|
||||
expectedStatus: number,
|
||||
expectedBody?: string,
|
||||
) {
|
||||
setup();
|
||||
return expectWebhookRequestStatusForTest(params, expectedStatus, expectedBody);
|
||||
}
|
||||
|
||||
async function dispatchWebhookPayloadWithSetup(setup: () => void, payload: unknown) {
|
||||
setup();
|
||||
return dispatchWebhookPayloadForTest({ body: payload });
|
||||
}
|
||||
|
||||
async function expectProtectedPasswordQueryRequestStatus(
|
||||
expectedStatus: number,
|
||||
password = TEST_WEBHOOK_PASSWORD,
|
||||
) {
|
||||
return expectWebhookRequestStatusForTest(
|
||||
createProtectedPasswordQueryRequestParams(password),
|
||||
expectedStatus,
|
||||
);
|
||||
}
|
||||
|
||||
async function expectProtectedWebhookRequestStatus(
|
||||
params: WebhookRequestParams,
|
||||
expectedStatus: number,
|
||||
expectedBody?: string,
|
||||
) {
|
||||
return expectWebhookRequestStatusWithSetup(
|
||||
() => {
|
||||
setupProtectedWebhookTarget();
|
||||
},
|
||||
params,
|
||||
expectedStatus,
|
||||
expectedBody,
|
||||
);
|
||||
}
|
||||
|
||||
async function expectRegisteredWebhookRequestStatus(
|
||||
params: WebhookRequestParams,
|
||||
expectedStatus: number,
|
||||
expectedBody?: string,
|
||||
) {
|
||||
return expectWebhookRequestStatusWithSetup(
|
||||
() => {
|
||||
setupWebhookTarget();
|
||||
},
|
||||
params,
|
||||
expectedStatus,
|
||||
expectedBody,
|
||||
);
|
||||
}
|
||||
|
||||
async function dispatchRegisteredWebhookPayload(payload: unknown) {
|
||||
return dispatchWebhookPayloadWithSetup(() => {
|
||||
setupWebhookTarget();
|
||||
}, payload);
|
||||
}
|
||||
|
||||
async function expectLoopbackWebhookRequestStatus(
|
||||
remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number],
|
||||
expectedStatus: number,
|
||||
overrides?: Omit<WebhookRequestParams, "remoteAddress">,
|
||||
) {
|
||||
return expectWebhookRequestStatusForTest(
|
||||
createLoopbackWebhookRequestParamsForTest(remoteAddress, { overrides }),
|
||||
expectedStatus,
|
||||
);
|
||||
}
|
||||
|
||||
async function expectProtectedLoopbackWebhookRequestStatus(
|
||||
remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number],
|
||||
expectedStatus: number,
|
||||
overrides?: Omit<WebhookRequestParams, "remoteAddress">,
|
||||
) {
|
||||
setupProtectedWebhookTarget();
|
||||
return expectLoopbackWebhookRequestStatus(remoteAddress, expectedStatus, overrides);
|
||||
}
|
||||
|
||||
async function expectPasswordlessLoopbackWebhookRequestStatus(
|
||||
remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number],
|
||||
expectedStatus: number,
|
||||
overrides?: Omit<WebhookRequestParams, "remoteAddress">,
|
||||
) {
|
||||
setupPasswordlessWebhookTarget();
|
||||
return expectLoopbackWebhookRequestStatus(remoteAddress, expectedStatus, overrides);
|
||||
}
|
||||
|
||||
function registerWebhookTargets(
|
||||
@@ -344,70 +311,37 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
statusSink?: (event: unknown) => void;
|
||||
}>,
|
||||
) {
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
const unregisterFns = params.map(({ account, statusSink }) =>
|
||||
registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
statusSink,
|
||||
trackWebhookRegistrationForTest(
|
||||
setupWebhookTargetsForTest({
|
||||
createCore: createMockRuntime,
|
||||
accounts: params,
|
||||
}),
|
||||
(nextUnregister) => {
|
||||
unregister = nextUnregister;
|
||||
},
|
||||
);
|
||||
|
||||
unregister = () => {
|
||||
for (const unregisterFn of unregisterFns) {
|
||||
unregisterFn();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function expectWebhookStatus(
|
||||
req: IncomingMessage,
|
||||
expectedStatus: number,
|
||||
expectedBody?: string,
|
||||
) {
|
||||
const { handled, res } = await dispatchWebhook(req);
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(expectedStatus);
|
||||
if (expectedBody !== undefined) {
|
||||
expect(res.body).toBe(expectedBody);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
describe("webhook parsing + auth handling", () => {
|
||||
it("rejects non-POST requests", async () => {
|
||||
setupWebhookTarget();
|
||||
const req = createWebhookRequestForTest({ method: "GET" });
|
||||
await expectWebhookStatus(req, 405);
|
||||
await expectRegisteredWebhookRequestStatus({ method: "GET" }, 405);
|
||||
});
|
||||
|
||||
it("accepts POST requests with valid JSON payload", async () => {
|
||||
setupWebhookTarget();
|
||||
const payload = createNewMessagePayload({ date: Date.now() });
|
||||
const req = createWebhookRequestForTest({ body: payload });
|
||||
await expectWebhookStatus(req, 200, "ok");
|
||||
const payload = createTimestampedNewMessagePayloadForTest();
|
||||
await expectRegisteredWebhookRequestStatus({ body: payload }, 200, "ok");
|
||||
});
|
||||
|
||||
it("rejects requests with invalid JSON", async () => {
|
||||
setupWebhookTarget();
|
||||
const req = createWebhookRequestForTest({ body: "invalid json {{" });
|
||||
await expectWebhookStatus(req, 400);
|
||||
await expectRegisteredWebhookRequestStatus({ body: "invalid json {{" }, 400);
|
||||
});
|
||||
|
||||
it("accepts URL-encoded payload wrappers", async () => {
|
||||
setupWebhookTarget();
|
||||
const payload = createNewMessagePayload({ date: Date.now() });
|
||||
const payload = createTimestampedNewMessagePayloadForTest();
|
||||
const encodedBody = new URLSearchParams({
|
||||
payload: JSON.stringify(payload),
|
||||
}).toString();
|
||||
const req = createWebhookRequestForTest({ body: encodedBody });
|
||||
await expectWebhookStatus(req, 200, "ok");
|
||||
await expectRegisteredWebhookRequestStatus({ body: encodedBody }, 200, "ok");
|
||||
});
|
||||
|
||||
it("returns 408 when request body times out (Slow-Loris protection)", async () => {
|
||||
@@ -416,11 +350,9 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
setupWebhookTarget();
|
||||
|
||||
// Create a request that never sends data or ends (simulates slow-loris)
|
||||
const { req, destroyMock } = createHangingWebhookRequest();
|
||||
const { req, destroyMock } = createHangingWebhookRequestForTest();
|
||||
|
||||
const res = createMockResponse();
|
||||
|
||||
const handledPromise = handleBlueBubblesWebhookRequest(req, res);
|
||||
const { res, handledPromise } = createWebhookDispatchForTest(req);
|
||||
|
||||
// Advance past the 30s timeout
|
||||
await vi.advanceTimersByTimeAsync(31_000);
|
||||
@@ -435,123 +367,78 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
});
|
||||
|
||||
it("rejects unauthorized requests before reading the body", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
setupWebhookTarget({ account });
|
||||
const { req } = createHangingWebhookRequest("/bluebubbles-webhook?password=wrong-token");
|
||||
setupProtectedWebhookTarget();
|
||||
const { req } = createHangingWebhookRequestForTest(
|
||||
"/bluebubbles-webhook?password=wrong-token",
|
||||
);
|
||||
const onSpy = vi.spyOn(req, "on");
|
||||
await expectWebhookStatus(req, 401);
|
||||
await expectWebhookStatusForTest(req, 401);
|
||||
expect(onSpy).not.toHaveBeenCalledWith("data", expect.any(Function));
|
||||
});
|
||||
|
||||
it("authenticates via password query parameter", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
setupWebhookTarget({ account });
|
||||
const req = createWebhookRequestForTest({
|
||||
url: "/bluebubbles-webhook?password=secret-token",
|
||||
body: createNewMessagePayload(),
|
||||
remoteAddress: "192.168.1.100",
|
||||
});
|
||||
await expectWebhookStatus(req, 200);
|
||||
await expectProtectedWebhookRequestStatus(createProtectedPasswordQueryRequestParams(), 200);
|
||||
});
|
||||
|
||||
it("authenticates via x-password header", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
setupWebhookTarget({ account });
|
||||
const req = createWebhookRequestForTest({
|
||||
body: createNewMessagePayload(),
|
||||
headers: { "x-password": "secret-token" }, // pragma: allowlist secret
|
||||
remoteAddress: "192.168.1.100",
|
||||
});
|
||||
await expectWebhookStatus(req, 200);
|
||||
await expectProtectedWebhookRequestStatus(
|
||||
createRemoteWebhookRequestParamsForTest({
|
||||
overrides: {
|
||||
headers: { "x-password": TEST_WEBHOOK_PASSWORD }, // pragma: allowlist secret
|
||||
},
|
||||
}),
|
||||
200,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unauthorized requests with wrong password", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
setupWebhookTarget({ account });
|
||||
const req = createWebhookRequestForTest({
|
||||
url: "/bluebubbles-webhook?password=wrong-token",
|
||||
body: createNewMessagePayload(),
|
||||
remoteAddress: "192.168.1.100",
|
||||
});
|
||||
await expectWebhookStatus(req, 401);
|
||||
await expectProtectedWebhookRequestStatus(
|
||||
createProtectedPasswordQueryRequestParams("wrong-token"),
|
||||
401,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects ambiguous routing when multiple targets match the same password", async () => {
|
||||
const accountA = createMockAccount({ password: "secret-token" });
|
||||
const accountB = createMockAccount({ password: "secret-token" });
|
||||
const sinkA = vi.fn();
|
||||
const sinkB = vi.fn();
|
||||
registerWebhookTargets([
|
||||
{ account: accountA, statusSink: sinkA },
|
||||
{ account: accountB, statusSink: sinkB },
|
||||
]);
|
||||
const targetA = createProtectedWebhookTarget();
|
||||
const targetB = createProtectedWebhookTarget();
|
||||
registerWebhookTargets([targetA, targetB]);
|
||||
|
||||
const req = createWebhookRequestForTest({
|
||||
url: "/bluebubbles-webhook?password=secret-token",
|
||||
body: createNewMessagePayload(),
|
||||
remoteAddress: "192.168.1.100",
|
||||
});
|
||||
await expectWebhookStatus(req, 401);
|
||||
expect(sinkA).not.toHaveBeenCalled();
|
||||
expect(sinkB).not.toHaveBeenCalled();
|
||||
await expectProtectedPasswordQueryRequestStatus(401);
|
||||
expect(targetA.statusSink).not.toHaveBeenCalled();
|
||||
expect(targetB.statusSink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores targets without passwords when a password-authenticated target matches", async () => {
|
||||
const accountStrict = createMockAccount({ password: "secret-token" });
|
||||
const accountWithoutPassword = createMockAccount({ password: undefined });
|
||||
const sinkStrict = vi.fn();
|
||||
const sinkWithoutPassword = vi.fn();
|
||||
registerWebhookTargets([
|
||||
{ account: accountStrict, statusSink: sinkStrict },
|
||||
{ account: accountWithoutPassword, statusSink: sinkWithoutPassword },
|
||||
]);
|
||||
const strictTarget = createProtectedWebhookTarget();
|
||||
const passwordlessTarget = createPasswordlessWebhookTarget();
|
||||
registerWebhookTargets([strictTarget, passwordlessTarget]);
|
||||
|
||||
const req = createWebhookRequestForTest({
|
||||
url: "/bluebubbles-webhook?password=secret-token",
|
||||
body: createNewMessagePayload(),
|
||||
remoteAddress: "192.168.1.100",
|
||||
});
|
||||
await expectWebhookStatus(req, 200);
|
||||
expect(sinkStrict).toHaveBeenCalledTimes(1);
|
||||
expect(sinkWithoutPassword).not.toHaveBeenCalled();
|
||||
await expectProtectedPasswordQueryRequestStatus(200);
|
||||
expect(strictTarget.statusSink).toHaveBeenCalledTimes(1);
|
||||
expect(passwordlessTarget.statusSink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires authentication for loopback requests when password is configured", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
setupWebhookTarget({ account });
|
||||
for (const remoteAddress of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) {
|
||||
const req = createWebhookRequestForTest({
|
||||
body: createNewMessagePayload(),
|
||||
remoteAddress,
|
||||
});
|
||||
await expectWebhookStatus(req, 401);
|
||||
for (const remoteAddress of LOOPBACK_REMOTE_ADDRESSES_FOR_TEST) {
|
||||
await expectProtectedLoopbackWebhookRequestStatus(remoteAddress, 401);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects targets without passwords for loopback and proxied-looking requests", async () => {
|
||||
const account = createMockAccount({ password: undefined });
|
||||
setupWebhookTarget({ account });
|
||||
|
||||
const headerVariants: Record<string, string>[] = [
|
||||
{ host: "localhost" },
|
||||
{ host: "localhost", "x-forwarded-for": "203.0.113.10" },
|
||||
{ host: "localhost", forwarded: "for=203.0.113.10;proto=https;host=example.com" },
|
||||
];
|
||||
for (const headers of headerVariants) {
|
||||
const req = createWebhookRequestForTest({
|
||||
body: createNewMessagePayload(),
|
||||
headers,
|
||||
remoteAddress: "127.0.0.1",
|
||||
});
|
||||
await expectWebhookStatus(req, 401);
|
||||
await expectPasswordlessLoopbackWebhookRequestStatus("127.0.0.1", 401, { headers });
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores unregistered webhook paths", async () => {
|
||||
const req = createMockRequest("POST", "/unregistered-path", {});
|
||||
const res = createMockResponse();
|
||||
|
||||
const handled = await handleBlueBubblesWebhookRequest(req, res);
|
||||
const { handled } = await dispatchWebhookPayloadForTest({
|
||||
url: "/unregistered-path",
|
||||
});
|
||||
|
||||
expect(handled).toBe(false);
|
||||
});
|
||||
@@ -560,19 +447,13 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
const { resolveChatGuidForTarget } = await import("./send.js");
|
||||
vi.mocked(resolveChatGuidForTarget).mockClear();
|
||||
|
||||
setupWebhookTarget({ account: createMockAccount({ groupPolicy: "open" }) });
|
||||
const payload = createNewMessagePayload({
|
||||
const payload = createTimestampedNewMessagePayloadForTest({
|
||||
text: "hello from group",
|
||||
isGroup: true,
|
||||
chatId: "123",
|
||||
date: Date.now(),
|
||||
});
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await dispatchRegisteredWebhookPayload(payload);
|
||||
|
||||
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -591,19 +472,13 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
return EMPTY_DISPATCH_RESULT;
|
||||
});
|
||||
|
||||
setupWebhookTarget({ account: createMockAccount({ groupPolicy: "open" }) });
|
||||
const payload = createNewMessagePayload({
|
||||
const payload = createTimestampedNewMessagePayloadForTest({
|
||||
text: "hello from group",
|
||||
isGroup: true,
|
||||
chat: { chatGuid: "iMessage;+;chat123456" },
|
||||
date: Date.now(),
|
||||
});
|
||||
|
||||
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
|
||||
const res = createMockResponse();
|
||||
|
||||
await handleBlueBubblesWebhookRequest(req, res);
|
||||
await flushAsync();
|
||||
await dispatchRegisteredWebhookPayload(payload);
|
||||
|
||||
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
|
||||
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
|
||||
|
||||
369
extensions/bluebubbles/src/monitor.webhook.test-helpers.ts
Normal file
369
extensions/bluebubbles/src/monitor.webhook.test-helpers.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { expect, vi } from "vitest";
|
||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
import { handleBlueBubblesWebhookRequest } from "./monitor.js";
|
||||
import { registerBlueBubblesWebhookTarget } from "./monitor.js";
|
||||
import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js";
|
||||
import { setBlueBubblesRuntime } from "./runtime.js";
|
||||
|
||||
export type WebhookRequestParams = {
|
||||
method?: string;
|
||||
url?: string;
|
||||
body?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
remoteAddress?: string;
|
||||
};
|
||||
|
||||
export const LOOPBACK_REMOTE_ADDRESSES_FOR_TEST = ["127.0.0.1", "::1", "::ffff:127.0.0.1"] as const;
|
||||
|
||||
export function createMockAccount(
|
||||
overrides: Partial<ResolvedBlueBubblesAccount["config"]> = {},
|
||||
): ResolvedBlueBubblesAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
config: {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test-password",
|
||||
dmPolicy: "open",
|
||||
groupPolicy: "open",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createProtectedWebhookAccountForTest(password = "test-password") {
|
||||
return createMockAccount({ password });
|
||||
}
|
||||
|
||||
export function createNewMessagePayloadForTest(dataOverrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "new-message",
|
||||
data: {
|
||||
text: "hello",
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
guid: "msg-1",
|
||||
...dataOverrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createTimestampedNewMessagePayloadForTest(
|
||||
dataOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
return createNewMessagePayloadForTest({
|
||||
...dataOverrides,
|
||||
date: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export function createMessageReactionPayloadForTest(dataOverrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "message-reaction",
|
||||
data: {
|
||||
handle: { address: "+15551234567" },
|
||||
isGroup: false,
|
||||
isFromMe: false,
|
||||
associatedMessageGuid: "msg-original-123",
|
||||
associatedMessageType: 2000,
|
||||
...dataOverrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createTimestampedMessageReactionPayloadForTest(
|
||||
dataOverrides: Record<string, unknown> = {},
|
||||
) {
|
||||
return createMessageReactionPayloadForTest({
|
||||
...dataOverrides,
|
||||
date: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export function createMockRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
body: unknown,
|
||||
headers: Record<string, string> = {},
|
||||
remoteAddress = "127.0.0.1",
|
||||
): IncomingMessage {
|
||||
if (headers.host === undefined) {
|
||||
headers.host = "localhost";
|
||||
}
|
||||
const parsedUrl = new URL(url, "http://localhost");
|
||||
const hasAuthQuery = parsedUrl.searchParams.has("guid") || parsedUrl.searchParams.has("password");
|
||||
const hasAuthHeader =
|
||||
headers["x-guid"] !== undefined ||
|
||||
headers["x-password"] !== undefined ||
|
||||
headers["x-bluebubbles-guid"] !== undefined ||
|
||||
headers.authorization !== undefined;
|
||||
if (!hasAuthQuery && !hasAuthHeader) {
|
||||
parsedUrl.searchParams.set("password", "test-password");
|
||||
}
|
||||
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
req.method = method;
|
||||
req.url = `${parsedUrl.pathname}${parsedUrl.search}`;
|
||||
req.headers = headers;
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress };
|
||||
|
||||
// Emit body data after a microtask.
|
||||
void Promise.resolve().then(() => {
|
||||
const bodyStr = typeof body === "string" ? body : JSON.stringify(body);
|
||||
req.emit("data", Buffer.from(bodyStr));
|
||||
req.emit("end");
|
||||
});
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
export function createMockRequestForTest(params: WebhookRequestParams = {}): IncomingMessage {
|
||||
return createMockRequest(
|
||||
params.method ?? "POST",
|
||||
params.url ?? "/bluebubbles-webhook",
|
||||
params.body ?? {},
|
||||
params.headers,
|
||||
params.remoteAddress,
|
||||
);
|
||||
}
|
||||
|
||||
export function createRemoteWebhookRequestParamsForTest(
|
||||
params: {
|
||||
body?: unknown;
|
||||
remoteAddress?: string;
|
||||
overrides?: WebhookRequestParams;
|
||||
} = {},
|
||||
): WebhookRequestParams {
|
||||
return {
|
||||
body: params.body ?? createNewMessagePayloadForTest(),
|
||||
remoteAddress: params.remoteAddress ?? "192.168.1.100",
|
||||
...params.overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPasswordQueryRequestParamsForTest(
|
||||
params: {
|
||||
body?: unknown;
|
||||
password?: string;
|
||||
remoteAddress?: string;
|
||||
overrides?: Omit<WebhookRequestParams, "url">;
|
||||
} = {},
|
||||
): WebhookRequestParams {
|
||||
return createRemoteWebhookRequestParamsForTest({
|
||||
body: params.body,
|
||||
remoteAddress: params.remoteAddress,
|
||||
overrides: {
|
||||
url: `/bluebubbles-webhook?password=${params.password ?? "test-password"}`,
|
||||
...params.overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createLoopbackWebhookRequestParamsForTest(
|
||||
remoteAddress: (typeof LOOPBACK_REMOTE_ADDRESSES_FOR_TEST)[number],
|
||||
params: {
|
||||
body?: unknown;
|
||||
overrides?: Omit<WebhookRequestParams, "remoteAddress">;
|
||||
} = {},
|
||||
): WebhookRequestParams {
|
||||
return {
|
||||
body: params.body ?? createNewMessagePayloadForTest(),
|
||||
remoteAddress,
|
||||
...params.overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createHangingWebhookRequestForTest(
|
||||
url = "/bluebubbles-webhook?password=test-password",
|
||||
remoteAddress = "127.0.0.1",
|
||||
) {
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
const destroyMock = vi.fn();
|
||||
req.method = "POST";
|
||||
req.url = url;
|
||||
req.headers = {};
|
||||
req.destroy = destroyMock as unknown as IncomingMessage["destroy"];
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = { remoteAddress };
|
||||
return { req, destroyMock };
|
||||
}
|
||||
|
||||
export function createMockResponse(): ServerResponse & { body: string; statusCode: number } {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: "",
|
||||
setHeader: vi.fn(),
|
||||
end: vi.fn((data?: string) => {
|
||||
res.body = data ?? "";
|
||||
}),
|
||||
} as unknown as ServerResponse & { body: string; statusCode: number };
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function flushAsync() {
|
||||
for (let i = 0; i < 2; i += 1) {
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
}
|
||||
}
|
||||
|
||||
export function createWebhookDispatchForTest(req: IncomingMessage) {
|
||||
const res = createMockResponse();
|
||||
const handledPromise = handleBlueBubblesWebhookRequest(req, res);
|
||||
return { res, handledPromise };
|
||||
}
|
||||
|
||||
export async function dispatchWebhookRequestForTest(
|
||||
req: IncomingMessage,
|
||||
options: { flushAsyncAfter?: boolean } = {},
|
||||
) {
|
||||
const { res, handledPromise } = createWebhookDispatchForTest(req);
|
||||
const handled = await handledPromise;
|
||||
if (options.flushAsyncAfter) {
|
||||
await flushAsync();
|
||||
}
|
||||
return { handled, res };
|
||||
}
|
||||
|
||||
export async function dispatchWebhookPayloadForTest(params: WebhookRequestParams = {}) {
|
||||
const req = createMockRequestForTest(params);
|
||||
return dispatchWebhookRequestForTest(req, { flushAsyncAfter: true });
|
||||
}
|
||||
|
||||
export async function expectWebhookStatusForTest(
|
||||
req: IncomingMessage,
|
||||
expectedStatus: number,
|
||||
expectedBody?: string,
|
||||
) {
|
||||
const { res, handled } = await dispatchWebhookRequestForTest(req);
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(expectedStatus);
|
||||
if (expectedBody !== undefined) {
|
||||
expect(res.body).toBe(expectedBody);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function expectWebhookRequestStatusForTest(
|
||||
params: WebhookRequestParams,
|
||||
expectedStatus: number,
|
||||
expectedBody?: string,
|
||||
) {
|
||||
return expectWebhookStatusForTest(createMockRequestForTest(params), expectedStatus, expectedBody);
|
||||
}
|
||||
|
||||
export function trackWebhookRegistrationForTest<T extends { unregister: () => void }>(
|
||||
registration: T,
|
||||
setUnregister: (unregister: () => void) => void,
|
||||
) {
|
||||
setUnregister(registration.unregister);
|
||||
return registration;
|
||||
}
|
||||
|
||||
export function registerWebhookTargetForTest(params: {
|
||||
core: PluginRuntime;
|
||||
account?: ResolvedBlueBubblesAccount;
|
||||
config?: OpenClawConfig;
|
||||
path?: string;
|
||||
statusSink?: (event: unknown) => void;
|
||||
runtime?: {
|
||||
log: (...args: unknown[]) => unknown;
|
||||
error: (...args: unknown[]) => unknown;
|
||||
};
|
||||
}) {
|
||||
setBlueBubblesRuntime(params.core);
|
||||
|
||||
return registerBlueBubblesWebhookTarget({
|
||||
account: params.account ?? createMockAccount(),
|
||||
config: params.config ?? {},
|
||||
runtime: params.runtime ?? { log: vi.fn(), error: vi.fn() },
|
||||
core: params.core,
|
||||
path: params.path ?? "/bluebubbles-webhook",
|
||||
statusSink: params.statusSink,
|
||||
});
|
||||
}
|
||||
|
||||
export function registerWebhookTargetsForTest(params: {
|
||||
core: PluginRuntime;
|
||||
accounts: Array<{
|
||||
account: ResolvedBlueBubblesAccount;
|
||||
statusSink?: (event: unknown) => void;
|
||||
}>;
|
||||
config?: OpenClawConfig;
|
||||
path?: string;
|
||||
runtime?: {
|
||||
log: (...args: unknown[]) => unknown;
|
||||
error: (...args: unknown[]) => unknown;
|
||||
};
|
||||
}) {
|
||||
return params.accounts.map(({ account, statusSink }) =>
|
||||
registerWebhookTargetForTest({
|
||||
core: params.core,
|
||||
account,
|
||||
config: params.config,
|
||||
path: params.path,
|
||||
runtime: params.runtime,
|
||||
statusSink,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function setupWebhookTargetForTest(params: {
|
||||
createCore: () => PluginRuntime;
|
||||
core?: PluginRuntime;
|
||||
account?: ResolvedBlueBubblesAccount;
|
||||
config?: OpenClawConfig;
|
||||
path?: string;
|
||||
statusSink?: (event: unknown) => void;
|
||||
runtime?: {
|
||||
log: (...args: unknown[]) => unknown;
|
||||
error: (...args: unknown[]) => unknown;
|
||||
};
|
||||
}) {
|
||||
const account = params.account ?? createMockAccount();
|
||||
const config = params.config ?? {};
|
||||
const core = params.core ?? params.createCore();
|
||||
const unregister = registerWebhookTargetForTest({
|
||||
core,
|
||||
account,
|
||||
config,
|
||||
path: params.path,
|
||||
statusSink: params.statusSink,
|
||||
runtime: params.runtime,
|
||||
});
|
||||
return { account, config, core, unregister };
|
||||
}
|
||||
|
||||
export function setupWebhookTargetsForTest(params: {
|
||||
createCore: () => PluginRuntime;
|
||||
core?: PluginRuntime;
|
||||
accounts: Array<{
|
||||
account: ResolvedBlueBubblesAccount;
|
||||
statusSink?: (event: unknown) => void;
|
||||
}>;
|
||||
config?: OpenClawConfig;
|
||||
path?: string;
|
||||
runtime?: {
|
||||
log: (...args: unknown[]) => unknown;
|
||||
error: (...args: unknown[]) => unknown;
|
||||
};
|
||||
}) {
|
||||
const core = params.core ?? params.createCore();
|
||||
const unregisterFns = registerWebhookTargetsForTest({
|
||||
core,
|
||||
accounts: params.accounts,
|
||||
config: params.config,
|
||||
path: params.path,
|
||||
runtime: params.runtime,
|
||||
});
|
||||
const unregister = () => {
|
||||
for (const unregisterFn of unregisterFns) {
|
||||
unregisterFn();
|
||||
}
|
||||
};
|
||||
return { core, unregister };
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export * from "../../../src/plugin-sdk/bluebubbles.js";
|
||||
export * from "openclaw/plugin-sdk/bluebubbles";
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
|
||||
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
|
||||
import {
|
||||
createSetupWizardAdapter,
|
||||
createTestWizardPrompter,
|
||||
runSetupWizardConfigure,
|
||||
type WizardPrompter,
|
||||
} from "../../../test/helpers/extensions/setup-wizard.js";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { DEFAULT_WEBHOOK_PATH } from "./webhook-shared.js";
|
||||
|
||||
@@ -27,17 +31,26 @@ async function createBlueBubblesConfigureAdapter() {
|
||||
}).config.allowFrom ?? [],
|
||||
},
|
||||
setup: blueBubblesSetupAdapter,
|
||||
} as Parameters<typeof buildChannelSetupWizardAdapterFromSetupWizard>[0]["plugin"];
|
||||
return buildChannelSetupWizardAdapterFromSetupWizard({
|
||||
} as Parameters<typeof createSetupWizardAdapter>[0]["plugin"];
|
||||
return createSetupWizardAdapter({
|
||||
plugin,
|
||||
wizard: blueBubblesSetupWizard,
|
||||
});
|
||||
}
|
||||
|
||||
async function runBlueBubblesConfigure(params: { cfg: unknown; prompter: WizardPrompter }) {
|
||||
const adapter = await createBlueBubblesConfigureAdapter();
|
||||
type ConfigureContext = Parameters<NonNullable<typeof adapter.configure>>[0];
|
||||
return await runSetupWizardConfigure({
|
||||
configure: adapter.configure,
|
||||
cfg: params.cfg as ConfigureContext["cfg"],
|
||||
runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"],
|
||||
prompter: params.prompter,
|
||||
});
|
||||
}
|
||||
|
||||
describe("bluebubbles setup surface", () => {
|
||||
it("preserves existing password SecretRef and keeps default webhook path", async () => {
|
||||
const adapter = await createBlueBubblesConfigureAdapter();
|
||||
type ConfigureContext = Parameters<NonNullable<typeof adapter.configure>>[0];
|
||||
const passwordRef = { source: "env", provider: "default", id: "BLUEBUBBLES_PASSWORD" };
|
||||
const confirm = vi
|
||||
.fn()
|
||||
@@ -45,10 +58,8 @@ describe("bluebubbles setup surface", () => {
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true);
|
||||
const text = vi.fn();
|
||||
const note = vi.fn();
|
||||
|
||||
const prompter = { confirm, text, note } as unknown as WizardPrompter;
|
||||
const context = {
|
||||
const result = await runBlueBubblesConfigure({
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
@@ -58,14 +69,8 @@ describe("bluebubbles setup surface", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
prompter,
|
||||
runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"],
|
||||
forceAllowFrom: false,
|
||||
accountOverrides: {},
|
||||
shouldPromptAccountIds: false,
|
||||
} satisfies ConfigureContext;
|
||||
|
||||
const result = await adapter.configure(context);
|
||||
prompter: createTestWizardPrompter({ confirm, text }),
|
||||
});
|
||||
|
||||
expect(result.cfg.channels?.bluebubbles?.password).toEqual(passwordRef);
|
||||
expect(result.cfg.channels?.bluebubbles?.webhookPath).toBe(DEFAULT_WEBHOOK_PATH);
|
||||
@@ -73,18 +78,14 @@ describe("bluebubbles setup surface", () => {
|
||||
});
|
||||
|
||||
it("applies a custom webhook path when requested", async () => {
|
||||
const adapter = await createBlueBubblesConfigureAdapter();
|
||||
type ConfigureContext = Parameters<NonNullable<typeof adapter.configure>>[0];
|
||||
const confirm = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true);
|
||||
const text = vi.fn().mockResolvedValueOnce("/custom-bluebubbles");
|
||||
const note = vi.fn();
|
||||
|
||||
const prompter = { confirm, text, note } as unknown as WizardPrompter;
|
||||
const context = {
|
||||
const result = await runBlueBubblesConfigure({
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
@@ -94,14 +95,8 @@ describe("bluebubbles setup surface", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
prompter,
|
||||
runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"],
|
||||
forceAllowFrom: false,
|
||||
accountOverrides: {},
|
||||
shouldPromptAccountIds: false,
|
||||
} satisfies ConfigureContext;
|
||||
|
||||
const result = await adapter.configure(context);
|
||||
prompter: createTestWizardPrompter({ confirm, text }),
|
||||
});
|
||||
|
||||
expect(result.cfg.channels?.bluebubbles?.webhookPath).toBe("/custom-bluebubbles");
|
||||
expect(text).toHaveBeenCalledWith(
|
||||
@@ -113,23 +108,13 @@ describe("bluebubbles setup surface", () => {
|
||||
});
|
||||
|
||||
it("validates server URLs before accepting input", async () => {
|
||||
const adapter = await createBlueBubblesConfigureAdapter();
|
||||
type ConfigureContext = Parameters<NonNullable<typeof adapter.configure>>[0];
|
||||
const confirm = vi.fn().mockResolvedValueOnce(false);
|
||||
const text = vi.fn().mockResolvedValueOnce("127.0.0.1:1234").mockResolvedValueOnce("secret");
|
||||
const note = vi.fn();
|
||||
|
||||
const prompter = { confirm, text, note } as unknown as WizardPrompter;
|
||||
const context = {
|
||||
await runBlueBubblesConfigure({
|
||||
cfg: { channels: { bluebubbles: {} } },
|
||||
prompter,
|
||||
runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"],
|
||||
forceAllowFrom: false,
|
||||
accountOverrides: {},
|
||||
shouldPromptAccountIds: false,
|
||||
} satisfies ConfigureContext;
|
||||
|
||||
await adapter.configure(context);
|
||||
prompter: createTestWizardPrompter({ confirm, text }),
|
||||
});
|
||||
|
||||
const serverUrlPrompt = text.mock.calls[0]?.[0] as {
|
||||
validate?: (value: string) => string | undefined;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type ParsedChatTarget,
|
||||
resolveServicePrefixedAllowTarget,
|
||||
resolveServicePrefixedTarget,
|
||||
} from "../../imessage/api.js";
|
||||
} from "openclaw/plugin-sdk/imessage-core";
|
||||
|
||||
export type BlueBubblesService = "imessage" | "sms" | "auto";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { ensureModelAllowlistEntry } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { buildBytePlusCodingProvider, buildBytePlusProvider } from "./provider-catalog.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
buildApiKeyCredential,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createDiagnosticsOtelService } from "./src/service.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
|
||||
@@ -10,6 +10,17 @@
|
||||
"https-proxy-agent": "^8.0.0",
|
||||
"opusscript": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.14"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelGatewayContext,
|
||||
} from "../../../src/channels/plugins/types.js";
|
||||
import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
|
||||
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
|
||||
import { createStartAccountContext } from "../../../test/helpers/extensions/start-account-context.js";
|
||||
import type { ResolvedDiscordAccount } from "./accounts.js";
|
||||
import { discordPlugin } from "./channel.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
@@ -49,31 +45,28 @@ function createCfg(): OpenClawConfig {
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function createStartAccountCtx(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
runtime: ReturnType<typeof createRuntimeEnv>;
|
||||
}): ChannelGatewayContext<ResolvedDiscordAccount> {
|
||||
const account = discordPlugin.config.resolveAccount(
|
||||
params.cfg,
|
||||
params.accountId,
|
||||
) as ResolvedDiscordAccount;
|
||||
const snapshot: ChannelAccountSnapshot = {
|
||||
accountId: params.accountId,
|
||||
configured: true,
|
||||
enabled: true,
|
||||
running: false,
|
||||
};
|
||||
return {
|
||||
accountId: params.accountId,
|
||||
account,
|
||||
cfg: params.cfg,
|
||||
runtime: params.runtime,
|
||||
abortSignal: new AbortController().signal,
|
||||
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
getStatus: () => snapshot,
|
||||
setStatus: vi.fn(),
|
||||
};
|
||||
function resolveAccount(cfg: OpenClawConfig): ResolvedDiscordAccount {
|
||||
return discordPlugin.config.resolveAccount(cfg, "default") as ResolvedDiscordAccount;
|
||||
}
|
||||
|
||||
function startDiscordAccount(cfg: OpenClawConfig) {
|
||||
return discordPlugin.gateway!.startAccount!(
|
||||
createStartAccountContext({
|
||||
account: resolveAccount(cfg),
|
||||
cfg,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function installDiscordRuntime(discord: Record<string, unknown>) {
|
||||
setDiscordRuntime({
|
||||
channel: {
|
||||
discord,
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
@@ -85,13 +78,9 @@ afterEach(() => {
|
||||
describe("discordPlugin outbound", () => {
|
||||
it("forwards mediaLocalRoots to sendMessageDiscord", async () => {
|
||||
const sendMessageDiscord = vi.fn(async () => ({ messageId: "m1" }));
|
||||
setDiscordRuntime({
|
||||
channel: {
|
||||
discord: {
|
||||
sendMessageDiscord,
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
installDiscordRuntime({
|
||||
sendMessageDiscord,
|
||||
});
|
||||
|
||||
const result = await discordPlugin.outbound!.sendMedia!({
|
||||
cfg: {} as OpenClawConfig,
|
||||
@@ -117,16 +106,9 @@ describe("discordPlugin outbound", () => {
|
||||
const runtimeProbeDiscord = vi.fn(async () => {
|
||||
throw new Error("runtime Discord probe should not be used");
|
||||
});
|
||||
setDiscordRuntime({
|
||||
channel: {
|
||||
discord: {
|
||||
probeDiscord: runtimeProbeDiscord,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
installDiscordRuntime({
|
||||
probeDiscord: runtimeProbeDiscord,
|
||||
});
|
||||
probeDiscordMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: { username: "Bob" },
|
||||
@@ -141,7 +123,7 @@ describe("discordPlugin outbound", () => {
|
||||
});
|
||||
|
||||
const cfg = createCfg();
|
||||
const account = discordPlugin.config.resolveAccount(cfg, "default");
|
||||
const account = resolveAccount(cfg);
|
||||
|
||||
await discordPlugin.status!.probeAccount!({
|
||||
account,
|
||||
@@ -162,17 +144,10 @@ describe("discordPlugin outbound", () => {
|
||||
const runtimeMonitorDiscordProvider = vi.fn(async () => {
|
||||
throw new Error("runtime Discord monitor should not be used");
|
||||
});
|
||||
setDiscordRuntime({
|
||||
channel: {
|
||||
discord: {
|
||||
probeDiscord: runtimeProbeDiscord,
|
||||
monitorDiscordProvider: runtimeMonitorDiscordProvider,
|
||||
},
|
||||
},
|
||||
logging: {
|
||||
shouldLogVerbose: () => false,
|
||||
},
|
||||
} as unknown as PluginRuntime);
|
||||
installDiscordRuntime({
|
||||
probeDiscord: runtimeProbeDiscord,
|
||||
monitorDiscordProvider: runtimeMonitorDiscordProvider,
|
||||
});
|
||||
probeDiscordMock.mockResolvedValue({
|
||||
ok: true,
|
||||
bot: { username: "Bob" },
|
||||
@@ -188,13 +163,7 @@ describe("discordPlugin outbound", () => {
|
||||
monitorDiscordProviderMock.mockResolvedValue(undefined);
|
||||
|
||||
const cfg = createCfg();
|
||||
await discordPlugin.gateway!.startAccount!(
|
||||
createStartAccountCtx({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
runtime: createRuntimeEnv(),
|
||||
}),
|
||||
);
|
||||
await startDiscordAccount(cfg);
|
||||
|
||||
expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 2500, {
|
||||
includeApplication: true,
|
||||
@@ -210,6 +179,37 @@ describe("discordPlugin outbound", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("discordPlugin security", () => {
|
||||
it("normalizes dm allowlist entries with trimmed prefixes and mentions", () => {
|
||||
const resolveDmPolicy = discordPlugin.security?.resolveDmPolicy;
|
||||
if (!resolveDmPolicy) {
|
||||
throw new Error("resolveDmPolicy unavailable");
|
||||
}
|
||||
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
token: "discord-token",
|
||||
dm: { policy: "allowlist", allowFrom: [" discord:<@!123456789> "] },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = resolveDmPolicy({
|
||||
cfg,
|
||||
account: discordPlugin.config.resolveAccount(cfg, "default") as ResolvedDiscordAccount,
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error("discord resolveDmPolicy returned null");
|
||||
}
|
||||
|
||||
expect(result.policy).toBe("allowlist");
|
||||
expect(result.allowFrom).toEqual([" discord:<@!123456789> "]);
|
||||
expect(result.normalizeEntry?.(" discord:<@!123456789> ")).toBe("123456789");
|
||||
expect(result.normalizeEntry?.(" user:987654321 ")).toBe("987654321");
|
||||
});
|
||||
});
|
||||
|
||||
describe("discordPlugin groups", () => {
|
||||
it("uses plugin-owned group policy resolvers", () => {
|
||||
const cfg = {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import {
|
||||
createRuntimeOutboundDelegates,
|
||||
resolveOutboundSendDep,
|
||||
} from "openclaw/plugin-sdk/infra-runtime";
|
||||
} from "openclaw/plugin-sdk/outbound-runtime";
|
||||
import {
|
||||
buildOutboundBaseSessionKey,
|
||||
normalizeMessageChannel,
|
||||
@@ -86,7 +86,12 @@ const resolveDiscordDmPolicy = createScopedDmSecurityResolver<ResolvedDiscordAcc
|
||||
resolvePolicy: (account) => account.config.dm?.policy,
|
||||
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
|
||||
allowFromPathSuffix: "dm.",
|
||||
normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
|
||||
normalizeEntry: (raw) =>
|
||||
raw
|
||||
.trim()
|
||||
.replace(/^(discord|user):/i, "")
|
||||
.trim()
|
||||
.replace(/^<@!?(\d+)>$/, "$1"),
|
||||
});
|
||||
|
||||
function formatDiscordIntents(intents?: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
listInspectedDirectoryEntriesFromSources,
|
||||
type DirectoryConfigParams,
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { inspectDiscordAccount, type InspectedDiscordAccount } from "../api.js";
|
||||
import { inspectDiscordAccount, type InspectedDiscordAccount } from "./account-inspect.js";
|
||||
|
||||
export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) {
|
||||
return listInspectedDirectoryEntriesFromSources({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ChannelType, MessageType } from "@buape/carbon";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
dispatchMock,
|
||||
loadConfigMock,
|
||||
readAllowFromStoreMock,
|
||||
sendMock,
|
||||
updateLastRouteMock,
|
||||
@@ -24,6 +25,7 @@ beforeEach(() => {
|
||||
});
|
||||
readAllowFromStoreMock.mockClear().mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockClear().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
loadConfigMock.mockClear().mockReturnValue(BASE_CFG);
|
||||
});
|
||||
|
||||
const BASE_CFG: Config = {
|
||||
@@ -33,6 +35,9 @@ const BASE_CFG: Config = {
|
||||
workspace: "/tmp/openclaw",
|
||||
},
|
||||
},
|
||||
messages: {
|
||||
inbound: { debounceMs: 0 },
|
||||
},
|
||||
session: { store: "/tmp/openclaw-sessions.json" },
|
||||
};
|
||||
|
||||
@@ -80,6 +85,7 @@ function createHandlerBaseConfig(
|
||||
}
|
||||
|
||||
async function createDmHandler(opts: { cfg: Config; runtimeError?: (err: unknown) => void }) {
|
||||
loadConfigMock.mockReturnValue(opts.cfg);
|
||||
return createDiscordMessageHandler(createHandlerBaseConfig(opts.cfg, opts.runtimeError));
|
||||
}
|
||||
|
||||
@@ -92,9 +98,10 @@ function createDmClient() {
|
||||
} as unknown as Client;
|
||||
}
|
||||
|
||||
async function createCategoryGuildHandler() {
|
||||
async function createCategoryGuildHandler(runtimeError?: (err: unknown) => void) {
|
||||
loadConfigMock.mockReturnValue(CATEGORY_GUILD_CFG);
|
||||
return createDiscordMessageHandler({
|
||||
...createHandlerBaseConfig(CATEGORY_GUILD_CFG),
|
||||
...createHandlerBaseConfig(CATEGORY_GUILD_CFG, runtimeError),
|
||||
guildEntries: {
|
||||
"*": { requireMention: false, channels: { c1: { allow: true } } },
|
||||
},
|
||||
|
||||
@@ -7,6 +7,7 @@ export const updateLastRouteMock: MockFn = vi.fn();
|
||||
export const dispatchMock: MockFn = vi.fn();
|
||||
export const readAllowFromStoreMock: MockFn = vi.fn();
|
||||
export const upsertPairingRequestMock: MockFn = vi.fn();
|
||||
export const loadConfigMock: MockFn = vi.fn();
|
||||
|
||||
vi.mock("./send.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./send.js")>();
|
||||
@@ -52,6 +53,8 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: (...args: unknown[]) => loadConfigMock(...args),
|
||||
readSessionUpdatedAt: vi.fn(() => undefined),
|
||||
resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"),
|
||||
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
|
||||
resolveSessionKey: vi.fn(),
|
||||
|
||||
@@ -156,6 +156,22 @@ describe("createDiscordMessageHandler queue behavior", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("drops duplicate inbound message deliveries before they reach preflight", async () => {
|
||||
preflightDiscordMessageMock.mockReset();
|
||||
processDiscordMessageMock.mockReset();
|
||||
|
||||
const handler = createHandlerWithDefaultPreflight();
|
||||
const duplicate = createMessageData("m-dup");
|
||||
|
||||
await expect(handler(duplicate as never, {} as never)).resolves.toBeUndefined();
|
||||
await expect(handler(duplicate as never, {} as never)).resolves.toBeUndefined();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(processDiscordMessageMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(preflightDiscordMessageMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies explicit inbound worker timeout to queued runs so stalled runs do not block the queue", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
shouldDebounceTextInbound,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { createDedupeCache } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { danger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { buildDiscordInboundJob } from "./inbound-job.js";
|
||||
import { createDiscordInboundWorker } from "./inbound-worker.js";
|
||||
@@ -30,6 +31,27 @@ export type DiscordMessageHandlerWithLifecycle = DiscordMessageHandler & {
|
||||
deactivate: () => void;
|
||||
};
|
||||
|
||||
const RECENT_DISCORD_MESSAGE_TTL_MS = 5 * 60_000;
|
||||
const RECENT_DISCORD_MESSAGE_MAX = 5000;
|
||||
|
||||
function buildDiscordInboundDedupeKey(params: {
|
||||
accountId: string;
|
||||
data: DiscordMessageEvent;
|
||||
}): string | null {
|
||||
const messageId = params.data.message?.id?.trim();
|
||||
if (!messageId) {
|
||||
return null;
|
||||
}
|
||||
const channelId = resolveDiscordMessageChannelId({
|
||||
message: params.data.message,
|
||||
eventChannelId: params.data.channel_id,
|
||||
});
|
||||
if (!channelId) {
|
||||
return null;
|
||||
}
|
||||
return `${params.accountId}:${channelId}:${messageId}`;
|
||||
}
|
||||
|
||||
export function createDiscordMessageHandler(
|
||||
params: DiscordMessageHandlerParams,
|
||||
): DiscordMessageHandlerWithLifecycle {
|
||||
@@ -48,6 +70,10 @@ export function createDiscordMessageHandler(
|
||||
abortSignal: params.abortSignal,
|
||||
runTimeoutMs: params.workerRunTimeoutMs,
|
||||
});
|
||||
const recentInboundMessages = createDedupeCache({
|
||||
ttlMs: RECENT_DISCORD_MESSAGE_TTL_MS,
|
||||
maxSize: RECENT_DISCORD_MESSAGE_MAX,
|
||||
});
|
||||
|
||||
const { debouncer } = createChannelInboundDebouncer<{
|
||||
data: DiscordMessageEvent;
|
||||
@@ -173,6 +199,13 @@ export function createDiscordMessageHandler(
|
||||
if (params.botUserId && msgAuthorId === params.botUserId) {
|
||||
return;
|
||||
}
|
||||
const dedupeKey = buildDiscordInboundDedupeKey({
|
||||
accountId: params.accountId,
|
||||
data,
|
||||
});
|
||||
if (dedupeKey && recentInboundMessages.check(dedupeKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await debouncer.enqueue({ data, client, abortSignal: options?.abortSignal });
|
||||
} catch (err) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { buildPluginBindingApprovalCustomId } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { peekSystemEvents, resetSystemEventsForTest } from "../../../../src/infra/system-events.js";
|
||||
import {
|
||||
clearDiscordComponentEntries,
|
||||
registerDiscordComponentEntries,
|
||||
@@ -50,7 +51,6 @@ import {
|
||||
|
||||
const readAllowFromStoreMock = vi.hoisted(() => vi.fn());
|
||||
const upsertPairingRequestMock = vi.hoisted(() => vi.fn());
|
||||
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
|
||||
const dispatchReplyMock = vi.hoisted(() => vi.fn());
|
||||
const recordInboundSessionMock = vi.hoisted(() => vi.fn());
|
||||
const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn());
|
||||
@@ -90,14 +90,6 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||
return {
|
||||
@@ -147,6 +139,12 @@ vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
|
||||
|
||||
describe("agent components", () => {
|
||||
const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig;
|
||||
const dmSessionKey = buildAgentSessionKey({
|
||||
agentId: "main",
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
peer: { kind: "direct", id: "123456789" },
|
||||
});
|
||||
|
||||
const createBaseDmInteraction = (overrides: Record<string, unknown> = {}) => {
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -187,7 +185,7 @@ describe("agent components", () => {
|
||||
beforeEach(() => {
|
||||
readAllowFromStoreMock.mockClear().mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockClear().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
enqueueSystemEventMock.mockClear();
|
||||
resetSystemEventsForTest();
|
||||
});
|
||||
|
||||
it("sends pairing reply when DM sender is not allowlisted", async () => {
|
||||
@@ -207,7 +205,7 @@ describe("agent components", () => {
|
||||
const code = pairingText.match(/Pairing code:\s*([A-Z2-9]{8})/)?.[1];
|
||||
expect(code).toBeDefined();
|
||||
expect(pairingText).toContain(`openclaw pairing approve discord ${code}`);
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
expect(peekSystemEvents(dmSessionKey)).toEqual([]);
|
||||
expect(readAllowFromStoreMock).toHaveBeenCalledWith("discord", "default");
|
||||
});
|
||||
|
||||
@@ -226,7 +224,7 @@ describe("agent components", () => {
|
||||
content: "You are not authorized to use this button.",
|
||||
ephemeral: true,
|
||||
});
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
expect(peekSystemEvents(dmSessionKey)).toEqual([]);
|
||||
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -243,7 +241,9 @@ describe("agent components", () => {
|
||||
|
||||
expect(defer).not.toHaveBeenCalled();
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalled();
|
||||
expect(peekSystemEvents(dmSessionKey)).toEqual([
|
||||
"[Discord component: hello clicked by Alice#1234 (123456789)]",
|
||||
]);
|
||||
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
|
||||
expect(readAllowFromStoreMock).toHaveBeenCalledWith("discord", "default");
|
||||
});
|
||||
@@ -261,7 +261,9 @@ describe("agent components", () => {
|
||||
|
||||
expect(defer).not.toHaveBeenCalled();
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalled();
|
||||
expect(peekSystemEvents(dmSessionKey)).toEqual([
|
||||
"[Discord component: hello clicked by Alice#1234 (123456789)]",
|
||||
]);
|
||||
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -281,7 +283,7 @@ describe("agent components", () => {
|
||||
content: "DM interactions are disabled.",
|
||||
ephemeral: true,
|
||||
});
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
expect(peekSystemEvents(dmSessionKey)).toEqual([]);
|
||||
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -299,7 +301,9 @@ describe("agent components", () => {
|
||||
|
||||
expect(defer).not.toHaveBeenCalled();
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalled();
|
||||
expect(peekSystemEvents(dmSessionKey)).toEqual([
|
||||
"[Discord select menu: hello interacted by Alice#1234 (123456789) (selected: alpha)]",
|
||||
]);
|
||||
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -316,10 +320,9 @@ describe("agent components", () => {
|
||||
|
||||
expect(defer).not.toHaveBeenCalled();
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("hello_cid"),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(peekSystemEvents(dmSessionKey)).toEqual([
|
||||
"[Discord component: hello_cid clicked by Alice#1234 (123456789)]",
|
||||
]);
|
||||
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -336,10 +339,9 @@ describe("agent components", () => {
|
||||
|
||||
expect(defer).not.toHaveBeenCalled();
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("hello%2G"),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(peekSystemEvents(dmSessionKey)).toEqual([
|
||||
"[Discord component: hello%2G clicked by Alice#1234 (123456789)]",
|
||||
]);
|
||||
expect(readAllowFromStoreMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -498,7 +500,7 @@ describe("discord component interactions", () => {
|
||||
lastDispatchCtx = undefined;
|
||||
readAllowFromStoreMock.mockClear().mockResolvedValue([]);
|
||||
upsertPairingRequestMock.mockClear().mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
enqueueSystemEventMock.mockClear();
|
||||
resetSystemEventsForTest();
|
||||
dispatchReplyMock.mockClear().mockImplementation(async (params: DispatchParams) => {
|
||||
lastDispatchCtx = params.ctx;
|
||||
await params.dispatcherOptions.deliver({ text: "ok" });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../../../../src/runtime.js";
|
||||
import { createNonExitingTypedRuntimeEnv } from "../../../../test/helpers/extensions/runtime-env.js";
|
||||
|
||||
const { resolveDiscordChannelAllowlistMock, resolveDiscordUserAllowlistMock } = vi.hoisted(() => ({
|
||||
resolveDiscordChannelAllowlistMock: vi.fn(
|
||||
@@ -35,7 +36,7 @@ import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js";
|
||||
|
||||
describe("resolveDiscordAllowlistConfig", () => {
|
||||
it("canonicalizes resolved user names to ids in runtime config", async () => {
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv;
|
||||
const runtime = createNonExitingTypedRuntimeEnv<RuntimeEnv>();
|
||||
const result = await resolveDiscordAllowlistConfig({
|
||||
token: "token",
|
||||
allowFrom: ["Alice", "111", "*"],
|
||||
@@ -69,7 +70,7 @@ describe("resolveDiscordAllowlistConfig", () => {
|
||||
channelName: "missing-room",
|
||||
},
|
||||
]);
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as unknown as RuntimeEnv;
|
||||
const runtime = createNonExitingTypedRuntimeEnv<RuntimeEnv>();
|
||||
|
||||
await resolveDiscordAllowlistConfig({
|
||||
token: "token",
|
||||
|
||||
@@ -4,7 +4,10 @@ import {
|
||||
createAttachedChannelResultAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-send-result";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { resolveOutboundSendDep, type OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import {
|
||||
resolveOutboundSendDep,
|
||||
type OutboundIdentity,
|
||||
} from "openclaw/plugin-sdk/outbound-runtime";
|
||||
import {
|
||||
resolvePayloadMediaUrls,
|
||||
sendPayloadMediaSequenceOrFallback,
|
||||
|
||||
@@ -4,7 +4,7 @@ export {
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
projectCredentialSnapshotFields,
|
||||
resolveConfiguredFromCredentialStatuses,
|
||||
} from "../../../src/plugin-sdk/discord.js";
|
||||
} from "openclaw/plugin-sdk/discord";
|
||||
export {
|
||||
buildChannelConfigSchema,
|
||||
getChatChannelMeta,
|
||||
@@ -19,15 +19,15 @@ export {
|
||||
type DiscordActionConfig,
|
||||
type DiscordConfig,
|
||||
type OpenClawConfig,
|
||||
} from "../../../src/plugin-sdk/discord-core.js";
|
||||
export { DiscordConfigSchema } from "../../../src/plugin-sdk/discord-core.js";
|
||||
} from "openclaw/plugin-sdk/discord-core";
|
||||
export { DiscordConfigSchema } from "openclaw/plugin-sdk/discord-core";
|
||||
export { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
|
||||
export {
|
||||
assertMediaNotDataUrl,
|
||||
parseAvailableTags,
|
||||
readReactionParams,
|
||||
withNormalizedTimestamp,
|
||||
} from "../../../src/plugin-sdk/discord-core.js";
|
||||
} from "openclaw/plugin-sdk/discord-core";
|
||||
export {
|
||||
createHybridChannelConfigAdapter,
|
||||
createScopedChannelConfigAdapter,
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
import { normalizeChatType } from "openclaw/plugin-sdk/account-resolution";
|
||||
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
|
||||
type DiscordSessionKeyContext = {
|
||||
ChatType?: string;
|
||||
From?: string;
|
||||
SenderId?: string;
|
||||
};
|
||||
|
||||
function normalizeDiscordChatType(raw?: string): "direct" | "group" | "channel" | undefined {
|
||||
const normalized = (raw ?? "").trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized === "dm") {
|
||||
return "direct";
|
||||
}
|
||||
if (normalized === "group" || normalized === "channel" || normalized === "direct") {
|
||||
return normalized;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeExplicitDiscordSessionKey(
|
||||
sessionKey: string,
|
||||
ctx: Pick<MsgContext, "ChatType" | "From" | "SenderId">,
|
||||
ctx: DiscordSessionKeyContext,
|
||||
): string {
|
||||
let normalized = sessionKey.trim().toLowerCase();
|
||||
if (normalizeChatType(ctx.ChatType) !== "direct") {
|
||||
if (normalizeDiscordChatType(ctx.ChatType) !== "direct") {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
|
||||
4
extensions/discord/timeouts.ts
Normal file
4
extensions/discord/timeouts.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
DISCORD_DEFAULT_INBOUND_WORKER_TIMEOUT_MS,
|
||||
DISCORD_DEFAULT_LISTENER_TIMEOUT_MS,
|
||||
} from "./src/monitor/timeouts.js";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildElevenLabsSpeechProvider } from "openclaw/plugin-sdk/speech";
|
||||
|
||||
export default definePluginEntry({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import { buildFalImageGenerationProvider } from "openclaw/plugin-sdk/image-generation";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { applyFalConfig, FAL_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js";
|
||||
|
||||
|
||||
@@ -9,6 +9,17 @@
|
||||
"https-proxy-agent": "^8.0.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.14"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Private runtime barrel for the bundled Feishu extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/feishu.js";
|
||||
export * from "openclaw/plugin-sdk/feishu";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
|
||||
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
|
||||
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
|
||||
import type { FeishuMessageEvent } from "./bot.js";
|
||||
import {
|
||||
@@ -99,16 +100,6 @@ vi.mock("../../../src/infra/outbound/session-binding-service.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
function createRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}),
|
||||
} as RuntimeEnv;
|
||||
}
|
||||
|
||||
async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) {
|
||||
const runtime = createRuntimeEnv();
|
||||
await handleFeishuMessage({
|
||||
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
createChannelDirectoryAdapter,
|
||||
createRuntimeDirectoryLiveAdapter,
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/outbound-runtime";
|
||||
import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "../runtime-api.js";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
|
||||
@@ -48,6 +48,26 @@ describe("feishu directory (config-backed)", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes spaced provider-prefixed peer entries", async () => {
|
||||
resolveFeishuAccountMock.mockReturnValueOnce({
|
||||
configured: false,
|
||||
config: {
|
||||
allowFrom: [" feishu:user:ou_alice "],
|
||||
dms: {
|
||||
" lark:dm:ou_carla ": {},
|
||||
},
|
||||
groups: {},
|
||||
groupAllowFrom: [],
|
||||
},
|
||||
});
|
||||
|
||||
const peers = await listFeishuDirectoryPeers({ cfg });
|
||||
expect(peers).toEqual([
|
||||
{ kind: "user", id: "ou_alice" },
|
||||
{ kind: "user", id: "ou_carla" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("merges groups map + groupAllowFrom into group entries", async () => {
|
||||
const groups = await listFeishuDirectoryGroups({ cfg });
|
||||
expect(groups).toEqual([
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
|
||||
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
|
||||
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
|
||||
import { monitorSingleAccount } from "./monitor.account.js";
|
||||
import { setFeishuRuntime } from "./runtime.js";
|
||||
@@ -139,14 +140,6 @@ function createLifecycleAccount(): ResolvedFeishuAccount {
|
||||
} as unknown as ResolvedFeishuAccount;
|
||||
}
|
||||
|
||||
function createRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as RuntimeEnv;
|
||||
}
|
||||
|
||||
function createTopicEvent(messageId: string) {
|
||||
return {
|
||||
sender: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
|
||||
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
|
||||
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
|
||||
import { monitorSingleAccount } from "./monitor.account.js";
|
||||
import { setFeishuRuntime } from "./runtime.js";
|
||||
@@ -133,14 +134,6 @@ function createLifecycleAccount(): ResolvedFeishuAccount {
|
||||
} as unknown as ResolvedFeishuAccount;
|
||||
}
|
||||
|
||||
function createRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as RuntimeEnv;
|
||||
}
|
||||
|
||||
function createBotMenuEvent(params: { eventKey: string; timestamp: string }) {
|
||||
return {
|
||||
event_key: params.eventKey,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
|
||||
import { createNonExitingRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
|
||||
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
|
||||
import { monitorSingleAccount } from "./monitor.account.js";
|
||||
import { setFeishuRuntime } from "./runtime.js";
|
||||
@@ -159,14 +160,6 @@ function createLifecycleAccount(accountId: "account-A" | "account-B"): ResolvedF
|
||||
} as unknown as ResolvedFeishuAccount;
|
||||
}
|
||||
|
||||
function createRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as RuntimeEnv;
|
||||
}
|
||||
|
||||
function createBroadcastEvent(messageId: string) {
|
||||
return {
|
||||
sender: {
|
||||
@@ -190,7 +183,7 @@ async function setupLifecycleMonitor(accountId: "account-A" | "account-B") {
|
||||
});
|
||||
createEventDispatcherMock.mockReturnValueOnce({ register });
|
||||
|
||||
const runtime = createRuntimeEnv();
|
||||
const runtime = createNonExitingRuntimeEnv();
|
||||
runtimesByAccount.set(accountId, runtime);
|
||||
|
||||
await monitorSingleAccount({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
|
||||
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
|
||||
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
|
||||
import { resetProcessedFeishuCardActionTokensForTests } from "./card-action.js";
|
||||
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
|
||||
@@ -135,14 +136,6 @@ function createLifecycleAccount(): ResolvedFeishuAccount {
|
||||
} as unknown as ResolvedFeishuAccount;
|
||||
}
|
||||
|
||||
function createRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as RuntimeEnv;
|
||||
}
|
||||
|
||||
function createCardActionEvent(params: {
|
||||
token: string;
|
||||
action: string;
|
||||
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
resolveInboundDebounceMs,
|
||||
} from "../../../src/auto-reply/inbound-debounce.js";
|
||||
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
|
||||
import {
|
||||
createNonExitingTypedRuntimeEnv,
|
||||
createRuntimeEnv,
|
||||
} from "../../../test/helpers/extensions/runtime-env.js";
|
||||
import type { ClawdbotConfig, RuntimeEnv } from "../runtime-api.js";
|
||||
import { parseFeishuMessageEvent, type FeishuMessageEvent } from "./bot.js";
|
||||
import * as dedup from "./dedup.js";
|
||||
@@ -172,11 +176,7 @@ async function setupDebounceMonitor(params?: {
|
||||
await monitorSingleAccount({
|
||||
cfg: buildDebounceConfig(),
|
||||
account: buildDebounceAccount(),
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as RuntimeEnv,
|
||||
runtime: createNonExitingTypedRuntimeEnv<RuntimeEnv>(),
|
||||
botOpenIdSource: {
|
||||
kind: "prefetched",
|
||||
botOpenId: params?.botOpenId ?? "ou_bot",
|
||||
@@ -452,11 +452,7 @@ describe("monitorSingleAccount lifecycle", () => {
|
||||
await monitorSingleAccount({
|
||||
cfg: buildDebounceConfig(),
|
||||
account: buildDebounceAccount(),
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as RuntimeEnv,
|
||||
runtime: createNonExitingTypedRuntimeEnv<RuntimeEnv>(),
|
||||
botOpenIdSource: {
|
||||
kind: "prefetched",
|
||||
botOpenId: "ou_bot",
|
||||
@@ -493,11 +489,7 @@ describe("monitorSingleAccount lifecycle", () => {
|
||||
monitorSingleAccount({
|
||||
cfg: buildDebounceConfig(),
|
||||
account: buildDebounceAccount(),
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as RuntimeEnv,
|
||||
runtime: createNonExitingTypedRuntimeEnv<RuntimeEnv>(),
|
||||
botOpenIdSource: {
|
||||
kind: "prefetched",
|
||||
botOpenId: "ou_bot",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
|
||||
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
|
||||
import type { ClawdbotConfig, PluginRuntime, RuntimeEnv } from "../runtime-api.js";
|
||||
import { monitorSingleAccount } from "./monitor.account.js";
|
||||
import { setFeishuRuntime } from "./runtime.js";
|
||||
@@ -140,14 +141,6 @@ function createLifecycleAccount(): ResolvedFeishuAccount {
|
||||
} as unknown as ResolvedFeishuAccount;
|
||||
}
|
||||
|
||||
function createRuntimeEnv(): RuntimeEnv {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as RuntimeEnv;
|
||||
}
|
||||
|
||||
function createTextEvent(messageId: string) {
|
||||
return {
|
||||
sender: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createNonExitingRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
|
||||
import type { ClawdbotConfig } from "../runtime-api.js";
|
||||
import { monitorFeishuProvider, stopFeishuMonitor } from "./monitor.js";
|
||||
|
||||
@@ -134,7 +135,7 @@ describe("Feishu monitor startup preflight", () => {
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
const runtime = createNonExitingRuntimeEnv();
|
||||
const monitorPromise = monitorFeishuProvider({
|
||||
config: buildMultiAccountWebsocketConfig(["alpha", "beta"]),
|
||||
runtime,
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
|
||||
import { createPluginSetupWizardStatus } from "../../../test/helpers/extensions/setup-wizard.js";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { feishuPlugin } from "./channel.js";
|
||||
|
||||
const feishuConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({
|
||||
plugin: feishuPlugin,
|
||||
wizard: feishuPlugin.setupWizard!,
|
||||
});
|
||||
const feishuGetStatus = createPluginSetupWizardStatus(feishuPlugin);
|
||||
|
||||
describe("feishu setup wizard status", () => {
|
||||
it("treats SecretRef appSecret as configured when appId is present", async () => {
|
||||
const status = await feishuConfigureAdapter.getStatus({
|
||||
const status = await feishuGetStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
|
||||
import { createNonExitingTypedRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
|
||||
import {
|
||||
createPluginSetupWizardConfigure,
|
||||
createPluginSetupWizardStatus,
|
||||
createTestWizardPrompter,
|
||||
runSetupWizardConfigure,
|
||||
} from "../../../test/helpers/extensions/setup-wizard.js";
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
probeFeishu: vi.fn(async () => ({ ok: false, error: "mocked" })),
|
||||
@@ -7,13 +13,6 @@ vi.mock("./probe.js", () => ({
|
||||
|
||||
import { feishuPlugin } from "./channel.js";
|
||||
|
||||
const baseConfigureContext = {
|
||||
runtime: {} as never,
|
||||
accountOverrides: {},
|
||||
shouldPromptAccountIds: false,
|
||||
forceAllowFrom: false,
|
||||
};
|
||||
|
||||
const baseStatusContext = {
|
||||
accountOverrides: {},
|
||||
};
|
||||
@@ -43,7 +42,7 @@ async function withEnvVars(values: Record<string, string | undefined>, run: () =
|
||||
}
|
||||
|
||||
async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: string }) {
|
||||
return await feishuConfigureAdapter.getStatus({
|
||||
return await feishuGetStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
@@ -56,10 +55,9 @@ async function getStatusWithEnvRefs(params: { appIdKey: string; appSecretKey: st
|
||||
});
|
||||
}
|
||||
|
||||
const feishuConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({
|
||||
plugin: feishuPlugin,
|
||||
wizard: feishuPlugin.setupWizard!,
|
||||
});
|
||||
const feishuConfigure = createPluginSetupWizardConfigure(feishuPlugin);
|
||||
const feishuGetStatus = createPluginSetupWizardStatus(feishuPlugin);
|
||||
type FeishuConfigureRuntime = Parameters<typeof feishuConfigure>[0]["runtime"];
|
||||
|
||||
describe("feishu setup wizard", () => {
|
||||
it("does not throw when config appId/appSecret are SecretRef objects", async () => {
|
||||
@@ -68,18 +66,17 @@ describe("feishu setup wizard", () => {
|
||||
.mockResolvedValueOnce("cli_from_prompt")
|
||||
.mockResolvedValueOnce("secret_from_prompt")
|
||||
.mockResolvedValueOnce("oc_group_1");
|
||||
|
||||
const prompter = {
|
||||
note: vi.fn(async () => undefined),
|
||||
const prompter = createTestWizardPrompter({
|
||||
text,
|
||||
confirm: vi.fn(async () => true),
|
||||
select: vi.fn(
|
||||
async ({ initialValue }: { initialValue?: string }) => initialValue ?? "allowlist",
|
||||
),
|
||||
} as never;
|
||||
) as never,
|
||||
});
|
||||
|
||||
await expect(
|
||||
feishuConfigureAdapter.configure({
|
||||
runSetupWizardConfigure({
|
||||
configure: feishuConfigure,
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
@@ -89,7 +86,7 @@ describe("feishu setup wizard", () => {
|
||||
},
|
||||
} as never,
|
||||
prompter,
|
||||
...baseConfigureContext,
|
||||
runtime: createNonExitingTypedRuntimeEnv<FeishuConfigureRuntime>(),
|
||||
}),
|
||||
).resolves.toBeTruthy();
|
||||
});
|
||||
@@ -97,7 +94,7 @@ describe("feishu setup wizard", () => {
|
||||
|
||||
describe("feishu setup wizard status", () => {
|
||||
it("does not fallback to top-level appId when account explicitly sets empty appId", async () => {
|
||||
const status = await feishuConfigureAdapter.getStatus({
|
||||
const status = await feishuGetStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { definePluginEntry, type AnyAgentTool } from "openclaw/plugin-sdk/core";
|
||||
import { definePluginEntry, type AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createFirecrawlScrapeTool } from "./src/firecrawl-scrape-tool.js";
|
||||
import { createFirecrawlWebSearchProvider } from "./src/firecrawl-search-provider.js";
|
||||
import { createFirecrawlSearchTool } from "./src/firecrawl-search-tool.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ensureAuthProfileStore, listProfilesForProvider } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { definePluginEntry, type ProviderAuthContext } from "openclaw/plugin-sdk/core";
|
||||
import { definePluginEntry, type ProviderAuthContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { coerceSecretRef } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { githubCopilotLoginCommand } from "openclaw/plugin-sdk/provider-auth-login";
|
||||
import { PROVIDER_ID, resolveCopilotForwardCompatModel } from "./models.js";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import { googlechatPlugin } from "./src/channel.js";
|
||||
import { setGoogleChatRuntime } from "./src/runtime.js";
|
||||
@@ -9,6 +10,6 @@ export default defineChannelPluginEntry({
|
||||
id: "googlechat",
|
||||
name: "Google Chat",
|
||||
description: "OpenClaw Google Chat channel plugin",
|
||||
plugin: googlechatPlugin,
|
||||
plugin: googlechatPlugin as ChannelPlugin,
|
||||
setRuntime: setGoogleChatRuntime,
|
||||
});
|
||||
|
||||
@@ -7,8 +7,11 @@
|
||||
"dependencies": {
|
||||
"google-auth-library": "^10.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.3.11"
|
||||
"openclaw": ">=2026.3.14"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Private runtime barrel for the bundled Google Chat extension.
|
||||
// Keep this barrel thin and aligned with the local extension surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/googlechat.js";
|
||||
export * from "openclaw/plugin-sdk/googlechat";
|
||||
|
||||
@@ -55,4 +55,32 @@ describe("googlechat directory", () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes spaced provider-prefixed dm allowlist entries", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
googlechat: {
|
||||
serviceAccount: { client_email: "bot@example.com" },
|
||||
dm: { allowFrom: [" users/alice ", " googlechat:user:Bob@Example.com "] },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
|
||||
const directory = expectDirectorySurface(googlechatPlugin.directory);
|
||||
|
||||
await expect(
|
||||
directory.listPeers({
|
||||
cfg,
|
||||
accountId: undefined,
|
||||
query: undefined,
|
||||
limit: undefined,
|
||||
runtime: runtimeEnv,
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ kind: "user", id: "users/alice" },
|
||||
{ kind: "user", id: "users/bob@example.com" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
abortStartedAccount,
|
||||
expectLifecyclePatch,
|
||||
expectPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
waitForStartedMocks,
|
||||
} from "../../../test/helpers/extensions/start-account-lifecycle.js";
|
||||
import type { ChannelAccountSnapshot } from "../runtime-api.js";
|
||||
import type { ResolvedGoogleChatAccount } from "./accounts.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
@@ -21,6 +21,21 @@ vi.mock("./monitor.js", async () => {
|
||||
|
||||
import { googlechatPlugin } from "./channel.js";
|
||||
|
||||
function buildAccount(): ResolvedGoogleChatAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
credentialSource: "inline",
|
||||
credentials: {},
|
||||
config: {
|
||||
webhookPath: "/googlechat",
|
||||
webhookUrl: "https://example.com/googlechat",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("googlechatPlugin gateway.startAccount", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -30,28 +45,12 @@ describe("googlechatPlugin gateway.startAccount", () => {
|
||||
const unregister = vi.fn();
|
||||
hoisted.startGoogleChatMonitor.mockResolvedValue(unregister);
|
||||
|
||||
const account: ResolvedGoogleChatAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
credentialSource: "inline",
|
||||
credentials: {},
|
||||
config: {
|
||||
webhookPath: "/googlechat",
|
||||
webhookUrl: "https://example.com/googlechat",
|
||||
audienceType: "app-url",
|
||||
audience: "https://example.com/googlechat",
|
||||
},
|
||||
};
|
||||
|
||||
const { abort, patches, task, isSettled } = startAccountAndTrackLifecycle({
|
||||
startAccount: googlechatPlugin.gateway!.startAccount!,
|
||||
account,
|
||||
account: buildAccount(),
|
||||
});
|
||||
await expectPendingUntilAbort({
|
||||
waitForStarted: () =>
|
||||
vi.waitFor(() => {
|
||||
expect(hoisted.startGoogleChatMonitor).toHaveBeenCalledOnce();
|
||||
}),
|
||||
waitForStarted: waitForStartedMocks(hoisted.startGoogleChatMonitor),
|
||||
isSettled,
|
||||
abort,
|
||||
task,
|
||||
@@ -62,7 +61,7 @@ describe("googlechatPlugin gateway.startAccount", () => {
|
||||
expect(unregister).toHaveBeenCalledOnce();
|
||||
},
|
||||
});
|
||||
expect(patches.some((entry) => entry.running === true)).toBe(true);
|
||||
expect(patches.some((entry) => entry.running === false)).toBe(true);
|
||||
expectLifecyclePatch(patches, { running: true });
|
||||
expectLifecyclePatch(patches, { running: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
||||
import {
|
||||
createScopedChannelConfigAdapter,
|
||||
createScopedDmSecurityResolver,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { createTextPairingAdapter } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { createScopedChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import {
|
||||
composeWarningCollectors,
|
||||
createAllowlistProviderGroupPolicyWarningCollector,
|
||||
createConditionalWarningCollector,
|
||||
createAllowlistProviderOpenWarningCollector,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
|
||||
import { createTopLevelChannelReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
createChannelDirectoryAdapter,
|
||||
listResolvedDirectoryGroupEntriesFromMapKeys,
|
||||
@@ -30,7 +25,6 @@ import {
|
||||
resolveChannelMediaMaxBytes,
|
||||
runPassiveAccountLifecycle,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
type ChannelStatusIssue,
|
||||
type OpenClawConfig,
|
||||
} from "../runtime-api.js";
|
||||
@@ -92,14 +86,6 @@ const googleChatConfigAdapter = createScopedChannelConfigAdapter<ResolvedGoogleC
|
||||
resolveDefaultTo: (account: ResolvedGoogleChatAccount) => account.config.defaultTo,
|
||||
});
|
||||
|
||||
const resolveGoogleChatDmPolicy = createScopedDmSecurityResolver<ResolvedGoogleChatAccount>({
|
||||
channelKey: "googlechat",
|
||||
resolvePolicy: (account) => account.config.dm?.policy,
|
||||
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
|
||||
allowFromPathSuffix: "dm.",
|
||||
normalizeEntry: (raw) => formatAllowFromEntry(raw),
|
||||
});
|
||||
|
||||
const googlechatActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: (ctx) => googlechatMessageActions.describeMessageTool?.(ctx) ?? null,
|
||||
extractToolSend: (ctx) => googlechatMessageActions.extractToolSend?.(ctx) ?? null,
|
||||
@@ -135,138 +121,258 @@ const collectGoogleChatSecurityWarnings = composeWarningCollectors<{
|
||||
),
|
||||
);
|
||||
|
||||
export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
||||
id: "googlechat",
|
||||
meta: { ...meta },
|
||||
setup: googlechatSetupAdapter,
|
||||
setupWizard: googlechatSetupWizard,
|
||||
pairing: createTextPairingAdapter({
|
||||
idLabel: "googlechatUserId",
|
||||
message: PAIRING_APPROVED_MESSAGE,
|
||||
normalizeAllowEntry: (entry) => formatAllowFromEntry(entry),
|
||||
notify: async ({ cfg, id, message }) => {
|
||||
const account = resolveGoogleChatAccount({ cfg: cfg });
|
||||
if (account.credentialSource === "none") {
|
||||
return;
|
||||
}
|
||||
const user = normalizeGoogleChatTarget(id) ?? id;
|
||||
const target = isGoogleChatUserTarget(user) ? user : `users/${user}`;
|
||||
const space = await resolveGoogleChatOutboundSpace({ account, target });
|
||||
const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime();
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space,
|
||||
text: message,
|
||||
});
|
||||
export const googlechatPlugin = createChatChannelPlugin({
|
||||
base: {
|
||||
id: "googlechat",
|
||||
meta: { ...meta },
|
||||
setup: googlechatSetupAdapter,
|
||||
setupWizard: googlechatSetupWizard,
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group", "thread"],
|
||||
reactions: true,
|
||||
threads: true,
|
||||
media: true,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
}),
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group", "thread"],
|
||||
reactions: true,
|
||||
threads: true,
|
||||
media: true,
|
||||
nativeCommands: false,
|
||||
blockStreaming: true,
|
||||
},
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
reload: { configPrefixes: ["channels.googlechat"] },
|
||||
configSchema: buildChannelConfigSchema(GoogleChatConfigSchema),
|
||||
config: {
|
||||
...googleChatConfigAdapter,
|
||||
isConfigured: (account) => account.credentialSource !== "none",
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.credentialSource !== "none",
|
||||
credentialSource: account.credentialSource,
|
||||
streaming: {
|
||||
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
|
||||
},
|
||||
reload: { configPrefixes: ["channels.googlechat"] },
|
||||
configSchema: buildChannelConfigSchema(GoogleChatConfigSchema),
|
||||
config: {
|
||||
...googleChatConfigAdapter,
|
||||
isConfigured: (account) => account.credentialSource !== "none",
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.credentialSource !== "none",
|
||||
credentialSource: account.credentialSource,
|
||||
}),
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveGoogleChatGroupRequireMention,
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeGoogleChatTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw, normalized) => {
|
||||
const value = normalized ?? raw.trim();
|
||||
return isGoogleChatSpaceTarget(value) || isGoogleChatUserTarget(value);
|
||||
},
|
||||
hint: "<spaces/{space}|users/{user}>",
|
||||
},
|
||||
},
|
||||
directory: createChannelDirectoryAdapter({
|
||||
listPeers: async (params) =>
|
||||
listResolvedDirectoryUserEntriesFromAllowFrom({
|
||||
...params,
|
||||
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }),
|
||||
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
|
||||
normalizeId: (entry) => normalizeGoogleChatTarget(entry) ?? entry,
|
||||
}),
|
||||
listGroups: async (params) =>
|
||||
listResolvedDirectoryGroupEntriesFromMapKeys({
|
||||
...params,
|
||||
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }),
|
||||
resolveGroups: (account) => account.config.groups,
|
||||
}),
|
||||
}),
|
||||
resolver: {
|
||||
resolveTargets: async ({ inputs, kind }) => {
|
||||
const resolved = inputs.map((input) => {
|
||||
const normalized = normalizeGoogleChatTarget(input);
|
||||
if (!normalized) {
|
||||
return { input, resolved: false, note: "empty target" };
|
||||
}
|
||||
if (kind === "user" && isGoogleChatUserTarget(normalized)) {
|
||||
return { input, resolved: true, id: normalized };
|
||||
}
|
||||
if (kind === "group" && isGoogleChatSpaceTarget(normalized)) {
|
||||
return { input, resolved: true, id: normalized };
|
||||
}
|
||||
return {
|
||||
input,
|
||||
resolved: false,
|
||||
note: "use spaces/{space} or users/{user}",
|
||||
};
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
},
|
||||
actions: googlechatActions,
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
collectStatusIssues: (accounts): ChannelStatusIssue[] =>
|
||||
accounts.flatMap((entry) => {
|
||||
const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID);
|
||||
const enabled = entry.enabled !== false;
|
||||
const configured = entry.configured === true;
|
||||
if (!enabled || !configured) {
|
||||
return [];
|
||||
}
|
||||
const issues: ChannelStatusIssue[] = [];
|
||||
if (!entry.audience) {
|
||||
issues.push({
|
||||
channel: "googlechat",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: "Google Chat audience is missing (set channels.googlechat.audience).",
|
||||
fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.",
|
||||
});
|
||||
}
|
||||
if (!entry.audienceType) {
|
||||
issues.push({
|
||||
channel: "googlechat",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: "Google Chat audienceType is missing (app-url or project-number).",
|
||||
fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.",
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}),
|
||||
buildChannelSummary: ({ snapshot }) =>
|
||||
buildPassiveProbedChannelStatusSummary(snapshot, {
|
||||
credentialSource: snapshot.credentialSource ?? "none",
|
||||
audienceType: snapshot.audienceType ?? null,
|
||||
audience: snapshot.audience ?? null,
|
||||
webhookPath: snapshot.webhookPath ?? null,
|
||||
webhookUrl: snapshot.webhookUrl ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account }) =>
|
||||
(await loadGoogleChatChannelRuntime()).probeGoogleChat(account),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
||||
const base = buildComputedAccountStatusSnapshot({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.credentialSource !== "none",
|
||||
runtime,
|
||||
probe,
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
credentialSource: account.credentialSource,
|
||||
audienceType: account.config.audienceType,
|
||||
audience: account.config.audience,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
dmPolicy: account.config.dm?.policy ?? "pairing",
|
||||
};
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
const statusSink = createAccountStatusSink({
|
||||
accountId: account.accountId,
|
||||
setStatus: ctx.setStatus,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`);
|
||||
const { resolveGoogleChatWebhookPath, startGoogleChatMonitor } =
|
||||
await loadGoogleChatChannelRuntime();
|
||||
statusSink({
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
webhookPath: resolveGoogleChatWebhookPath({ account }),
|
||||
audienceType: account.config.audienceType,
|
||||
audience: account.config.audience,
|
||||
});
|
||||
await runPassiveAccountLifecycle({
|
||||
abortSignal: ctx.abortSignal,
|
||||
start: async () =>
|
||||
await startGoogleChatMonitor({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
statusSink,
|
||||
}),
|
||||
stop: async (unregister) => {
|
||||
unregister?.();
|
||||
},
|
||||
onStop: async () => {
|
||||
statusSink({
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
text: {
|
||||
idLabel: "googlechatUserId",
|
||||
message: PAIRING_APPROVED_MESSAGE,
|
||||
normalizeAllowEntry: (entry) => formatAllowFromEntry(entry),
|
||||
notify: async ({ cfg, id, message }) => {
|
||||
const account = resolveGoogleChatAccount({ cfg: cfg });
|
||||
if (account.credentialSource === "none") {
|
||||
return;
|
||||
}
|
||||
const user = normalizeGoogleChatTarget(id) ?? id;
|
||||
const target = isGoogleChatUserTarget(user) ? user : `users/${user}`;
|
||||
const space = await resolveGoogleChatOutboundSpace({ account, target });
|
||||
const { sendGoogleChatMessage } = await loadGoogleChatChannelRuntime();
|
||||
await sendGoogleChatMessage({
|
||||
account,
|
||||
space,
|
||||
text: message,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: resolveGoogleChatDmPolicy,
|
||||
dm: {
|
||||
channelKey: "googlechat",
|
||||
resolvePolicy: (account) => account.config.dm?.policy,
|
||||
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
|
||||
allowFromPathSuffix: "dm.",
|
||||
normalizeEntry: (raw) => formatAllowFromEntry(raw),
|
||||
},
|
||||
collectWarnings: collectGoogleChatSecurityWarnings,
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveGoogleChatGroupRequireMention,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: createTopLevelChannelReplyToModeResolver("googlechat"),
|
||||
topLevelReplyToMode: "googlechat",
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeGoogleChatTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: (raw, normalized) => {
|
||||
const value = normalized ?? raw.trim();
|
||||
return isGoogleChatSpaceTarget(value) || isGoogleChatUserTarget(value);
|
||||
},
|
||||
hint: "<spaces/{space}|users/{user}>",
|
||||
},
|
||||
},
|
||||
directory: createChannelDirectoryAdapter({
|
||||
listPeers: async (params) =>
|
||||
listResolvedDirectoryUserEntriesFromAllowFrom({
|
||||
...params,
|
||||
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }),
|
||||
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
|
||||
normalizeId: (entry) => normalizeGoogleChatTarget(entry) ?? entry,
|
||||
}),
|
||||
listGroups: async (params) =>
|
||||
listResolvedDirectoryGroupEntriesFromMapKeys({
|
||||
...params,
|
||||
resolveAccount: (cfg, accountId) => resolveGoogleChatAccount({ cfg, accountId }),
|
||||
resolveGroups: (account) => account.config.groups,
|
||||
}),
|
||||
}),
|
||||
resolver: {
|
||||
resolveTargets: async ({ inputs, kind }) => {
|
||||
const resolved = inputs.map((input) => {
|
||||
const normalized = normalizeGoogleChatTarget(input);
|
||||
if (!normalized) {
|
||||
return { input, resolved: false, note: "empty target" };
|
||||
}
|
||||
if (kind === "user" && isGoogleChatUserTarget(normalized)) {
|
||||
return { input, resolved: true, id: normalized };
|
||||
}
|
||||
if (kind === "group" && isGoogleChatSpaceTarget(normalized)) {
|
||||
return { input, resolved: true, id: normalized };
|
||||
}
|
||||
return {
|
||||
input,
|
||||
resolved: false,
|
||||
note: "use spaces/{space} or users/{user}",
|
||||
};
|
||||
});
|
||||
return resolved;
|
||||
},
|
||||
},
|
||||
actions: googlechatActions,
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
resolveTarget: ({ to }) => {
|
||||
const trimmed = to?.trim() ?? "";
|
||||
base: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
resolveTarget: ({ to }) => {
|
||||
const trimmed = to?.trim() ?? "";
|
||||
|
||||
if (trimmed) {
|
||||
const normalized = normalizeGoogleChatTarget(trimmed);
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false,
|
||||
error: missingTargetError("Google Chat", "<spaces/{space}|users/{user}>"),
|
||||
};
|
||||
if (trimmed) {
|
||||
const normalized = normalizeGoogleChatTarget(trimmed);
|
||||
if (!normalized) {
|
||||
return {
|
||||
ok: false,
|
||||
error: missingTargetError("Google Chat", "<spaces/{space}|users/{user}>"),
|
||||
};
|
||||
}
|
||||
return { ok: true, to: normalized };
|
||||
}
|
||||
return { ok: true, to: normalized };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: missingTargetError("Google Chat", "<spaces/{space}|users/{user}>"),
|
||||
};
|
||||
return {
|
||||
ok: false,
|
||||
error: missingTargetError("Google Chat", "<spaces/{space}|users/{user}>"),
|
||||
};
|
||||
},
|
||||
},
|
||||
...createAttachedChannelResultAdapter({
|
||||
attachedResults: {
|
||||
channel: "googlechat",
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
|
||||
const account = resolveGoogleChatAccount({
|
||||
@@ -356,114 +462,6 @@ export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
|
||||
chatId: space,
|
||||
};
|
||||
},
|
||||
}),
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
collectStatusIssues: (accounts): ChannelStatusIssue[] =>
|
||||
accounts.flatMap((entry) => {
|
||||
const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID);
|
||||
const enabled = entry.enabled !== false;
|
||||
const configured = entry.configured === true;
|
||||
if (!enabled || !configured) {
|
||||
return [];
|
||||
}
|
||||
const issues: ChannelStatusIssue[] = [];
|
||||
if (!entry.audience) {
|
||||
issues.push({
|
||||
channel: "googlechat",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: "Google Chat audience is missing (set channels.googlechat.audience).",
|
||||
fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.",
|
||||
});
|
||||
}
|
||||
if (!entry.audienceType) {
|
||||
issues.push({
|
||||
channel: "googlechat",
|
||||
accountId,
|
||||
kind: "config",
|
||||
message: "Google Chat audienceType is missing (app-url or project-number).",
|
||||
fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.",
|
||||
});
|
||||
}
|
||||
return issues;
|
||||
}),
|
||||
buildChannelSummary: ({ snapshot }) =>
|
||||
buildPassiveProbedChannelStatusSummary(snapshot, {
|
||||
credentialSource: snapshot.credentialSource ?? "none",
|
||||
audienceType: snapshot.audienceType ?? null,
|
||||
audience: snapshot.audience ?? null,
|
||||
webhookPath: snapshot.webhookPath ?? null,
|
||||
webhookUrl: snapshot.webhookUrl ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account }) =>
|
||||
(await loadGoogleChatChannelRuntime()).probeGoogleChat(account),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => {
|
||||
const base = buildComputedAccountStatusSnapshot({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.credentialSource !== "none",
|
||||
runtime,
|
||||
probe,
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
credentialSource: account.credentialSource,
|
||||
audienceType: account.config.audienceType,
|
||||
audience: account.config.audience,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
dmPolicy: account.config.dm?.policy ?? "pairing",
|
||||
};
|
||||
},
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
const statusSink = createAccountStatusSink({
|
||||
accountId: account.accountId,
|
||||
setStatus: ctx.setStatus,
|
||||
});
|
||||
ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`);
|
||||
const { resolveGoogleChatWebhookPath, startGoogleChatMonitor } =
|
||||
await loadGoogleChatChannelRuntime();
|
||||
statusSink({
|
||||
running: true,
|
||||
lastStartAt: Date.now(),
|
||||
webhookPath: resolveGoogleChatWebhookPath({ account }),
|
||||
audienceType: account.config.audienceType,
|
||||
audience: account.config.audience,
|
||||
});
|
||||
await runPassiveAccountLifecycle({
|
||||
abortSignal: ctx.abortSignal,
|
||||
start: async () =>
|
||||
await startGoogleChatMonitor({
|
||||
account,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
webhookPath: account.config.webhookPath,
|
||||
webhookUrl: account.config.webhookUrl,
|
||||
statusSink,
|
||||
}),
|
||||
stop: async (unregister) => {
|
||||
unregister?.();
|
||||
},
|
||||
onStop: async () => {
|
||||
statusSink({
|
||||
running: false,
|
||||
lastStopAt: Date.now(),
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildChannelSetupWizardAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
|
||||
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
|
||||
import {
|
||||
createPluginSetupWizardConfigure,
|
||||
createTestWizardPrompter,
|
||||
runSetupWizardConfigure,
|
||||
type WizardPrompter,
|
||||
} from "../../../test/helpers/extensions/setup-wizard.js";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { googlechatPlugin } from "./channel.js";
|
||||
|
||||
const googlechatConfigureAdapter = buildChannelSetupWizardAdapterFromSetupWizard({
|
||||
plugin: googlechatPlugin,
|
||||
wizard: googlechatPlugin.setupWizard!,
|
||||
});
|
||||
const googlechatConfigure = createPluginSetupWizardConfigure(googlechatPlugin);
|
||||
|
||||
describe("googlechat setup wizard", () => {
|
||||
it("configures service-account auth and webhook audience", async () => {
|
||||
@@ -27,16 +24,11 @@ describe("googlechat setup wizard", () => {
|
||||
}) as WizardPrompter["text"],
|
||||
});
|
||||
|
||||
const runtime = createRuntimeEnv();
|
||||
|
||||
const result = await googlechatConfigureAdapter.configure({
|
||||
const result = await runSetupWizardConfigure({
|
||||
configure: googlechatConfigure,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime,
|
||||
prompter,
|
||||
options: {},
|
||||
accountOverrides: {},
|
||||
shouldPromptAccountIds: false,
|
||||
forceAllowFrom: false,
|
||||
});
|
||||
|
||||
expect(result.accountId).toBe("default");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { applyHuggingfaceConfig, HUGGINGFACE_DEFAULT_MODEL_REF } from "./onboard.js";
|
||||
import { buildHuggingfaceProvider } from "./provider-catalog.js";
|
||||
|
||||
@@ -13,7 +13,7 @@ export {
|
||||
IMessageConfigSchema,
|
||||
type ChannelPlugin,
|
||||
type IMessageAccountConfig,
|
||||
} from "../../src/plugin-sdk/imessage.js";
|
||||
} from "openclaw/plugin-sdk/imessage";
|
||||
export {
|
||||
resolveIMessageGroupRequireMention,
|
||||
resolveIMessageGroupToolPolicy,
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { imessagePlugin } from "./channel.js";
|
||||
|
||||
function requireIMessageSendText() {
|
||||
const sendText = imessagePlugin.outbound?.sendText;
|
||||
if (!sendText) {
|
||||
throw new Error("imessage outbound.sendText unavailable");
|
||||
}
|
||||
return sendText;
|
||||
}
|
||||
|
||||
function requireIMessageSendMedia() {
|
||||
const sendMedia = imessagePlugin.outbound?.sendMedia;
|
||||
if (!sendMedia) {
|
||||
throw new Error("imessage outbound.sendMedia unavailable");
|
||||
}
|
||||
return sendMedia;
|
||||
}
|
||||
|
||||
describe("imessagePlugin outbound", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
@@ -12,10 +28,9 @@ describe("imessagePlugin outbound", () => {
|
||||
|
||||
it("forwards replyToId on direct sendText adapter path", async () => {
|
||||
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "m-text" });
|
||||
const sendText = imessagePlugin.outbound?.sendText;
|
||||
expect(sendText).toBeDefined();
|
||||
const sendText = requireIMessageSendText();
|
||||
|
||||
const result = await sendText!({
|
||||
const result = await sendText({
|
||||
cfg,
|
||||
to: "chat_id:12",
|
||||
text: "hello",
|
||||
@@ -38,10 +53,9 @@ describe("imessagePlugin outbound", () => {
|
||||
|
||||
it("forwards replyToId on direct sendMedia adapter path", async () => {
|
||||
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "m-media" });
|
||||
const sendMedia = imessagePlugin.outbound?.sendMedia;
|
||||
expect(sendMedia).toBeDefined();
|
||||
const sendMedia = requireIMessageSendMedia();
|
||||
|
||||
const result = await sendMedia!({
|
||||
const result = await sendMedia({
|
||||
cfg,
|
||||
to: "chat_id:77",
|
||||
text: "caption",
|
||||
@@ -66,11 +80,10 @@ describe("imessagePlugin outbound", () => {
|
||||
|
||||
it("forwards mediaLocalRoots on direct sendMedia adapter path", async () => {
|
||||
const sendIMessage = vi.fn().mockResolvedValue({ messageId: "m-media-local" });
|
||||
const sendMedia = imessagePlugin.outbound?.sendMedia;
|
||||
expect(sendMedia).toBeDefined();
|
||||
const sendMedia = requireIMessageSendMedia();
|
||||
const mediaLocalRoots = ["/tmp/workspace"];
|
||||
|
||||
const result = await sendMedia!({
|
||||
const result = await sendMedia({
|
||||
cfg,
|
||||
to: "chat_id:88",
|
||||
text: "caption",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/outbound-runtime";
|
||||
import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes } from "../runtime-api.js";
|
||||
import type { ResolvedIMessageAccount } from "./accounts.js";
|
||||
import { monitorIMessageProvider } from "./monitor.js";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user