mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 15:03:05 +08:00
Compare commits
8 Commits
fix/config
...
split/gate
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d4b640499 | ||
|
|
f83476364d | ||
|
|
551efb9fc9 | ||
|
|
5ef811771a | ||
|
|
fa90b3c92b | ||
|
|
6e8ed7af3a | ||
|
|
5ccabe9e63 | ||
|
|
aab3c4d2f0 |
2
.github/workflows/auto-response.yml
vendored
2
.github/workflows/auto-response.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
|
||||
139
.github/workflows/formal-conformance.yml
vendored
Normal file
139
.github/workflows/formal-conformance.yml
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
name: Formal models (informational conformance)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: formal-conformance-${{ github.event.pull_request.number || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
formal_conformance:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout openclaw (PR)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: openclaw
|
||||
|
||||
- name: Checkout formal models
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: vignesh07/clawdbot-formal-models
|
||||
ref: main
|
||||
path: clawdbot-formal-models
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Regenerate extracted constants from openclaw
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd clawdbot-formal-models
|
||||
export OPENCLAW_REPO_DIR="${GITHUB_WORKSPACE}/openclaw"
|
||||
node scripts/extract-tool-groups.mjs
|
||||
node scripts/check-tool-group-alias.mjs
|
||||
|
||||
# Drift is about extracted artifacts only; compute it before model checking
|
||||
# to avoid any incidental file touches affecting the result.
|
||||
- name: Compute drift (generated/*)
|
||||
id: drift
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd clawdbot-formal-models
|
||||
|
||||
if git diff --quiet -- generated; then
|
||||
echo "drift=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "drift=true" >> "$GITHUB_OUTPUT"
|
||||
git diff -- generated > "${GITHUB_WORKSPACE}/formal-models-drift.diff"
|
||||
|
||||
- name: Model check (green suite)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd clawdbot-formal-models
|
||||
make \
|
||||
precedence groups elevated nodes-policy \
|
||||
attacker approvals approvals-token nodes-pipeline \
|
||||
gateway-exposure gateway-exposure-v2 gateway-exposure-v2-protected \
|
||||
gateway-auth-conformance gateway-auth-tailscale gateway-auth-proxy \
|
||||
pairing pairing-cap pairing-idempotency pairing-refresh pairing-refresh-race \
|
||||
ingress-gating ingress-idempotency ingress-dedupe-fallback ingress-trace ingress-trace2 \
|
||||
routing-isolation routing-precedence routing-identitylinks routing-identity-transitive routing-identity-symmetry routing-identity-channel-override \
|
||||
routing-thread-parent discord-pluralkit \
|
||||
ingress-retry session-key-stability session-explosion-bound config-normalization \
|
||||
queue-drain delivery-route-stability delivery-pipeline retry-termination retry-eventual-success \
|
||||
no-cross-stream multi-event-eventual-emission \
|
||||
dedupe-collision-fallback crash-restart-dedupe two-worker-dedupe openclaw-session-key-conformance \
|
||||
routing-thread-parent-channel-override routing-trirule gateway-auth-proxy-header-spoof \
|
||||
group-alias-check
|
||||
|
||||
- name: Model check (negative suite, expected violations)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd clawdbot-formal-models
|
||||
make -k \
|
||||
precedence-negative groups-negative elevated-negative nodes-policy-negative \
|
||||
attacker-negative attacker-nodes-negative attacker-nodes-allowlist-negative attacker-nodes-allowlist-negative \
|
||||
approvals-negative approvals-token-negative nodes-pipeline-negative \
|
||||
gateway-exposure-negative gateway-exposure-v2-negative gateway-exposure-v2-protected-negative \
|
||||
gateway-exposure-v2-unsafe-custom gateway-exposure-v2-unsafe-tailnet gateway-exposure-v2-unsafe-auto \
|
||||
gateway-auth-conformance-negative gateway-auth-tailscale-negative gateway-auth-proxy-negative \
|
||||
pairing-negative pairing-cap-negative pairing-idempotency-negative pairing-refresh-negative pairing-refresh-race-negative \
|
||||
ingress-gating-negative ingress-idempotency-negative ingress-dedupe-fallback-negative ingress-trace-negative ingress-trace2-negative \
|
||||
routing-isolation-negative routing-precedence-negative routing-identitylinks-negative routing-identity-transitive-negative routing-identity-symmetry-negative routing-identity-channel-override-negative \
|
||||
routing-thread-parent-negative discord-pluralkit-negative \
|
||||
ingress-retry-negative session-key-stability-negative config-normalization-negative \
|
||||
queue-drain delivery-route-stability-negative delivery-pipeline-negative retry-termination-negative retry-eventual-success-negative \
|
||||
no-cross-stream-negative multi-event-eventual-emission-negative \
|
||||
dedupe-collision-fallback-negative crash-restart-dedupe-negative two-worker-dedupe-negative openclaw-session-key-conformance-negative \
|
||||
routing-thread-parent-channel-override-negative routing-trirule-negative gateway-auth-proxy-header-spoof-negative
|
||||
|
||||
- name: Upload drift diff artifact
|
||||
if: steps.drift.outputs.drift == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: formal-models-conformance-drift
|
||||
path: formal-models-drift.diff
|
||||
|
||||
- name: Comment on PR (informational)
|
||||
if: steps.drift.outputs.drift == 'true'
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const body = [
|
||||
'⚠️ **Formal models conformance drift detected**',
|
||||
'',
|
||||
'The formal models extracted constants (`generated/*`) do not match this openclaw PR.',
|
||||
'',
|
||||
'This check is **informational** (not blocking merges yet).',
|
||||
'See the `formal-models-conformance-drift` artifact for the diff.',
|
||||
'',
|
||||
'If this change is intentional, follow up by updating the formal models repo or regenerating the extracted artifacts there.',
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
body,
|
||||
});
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
if [ "${{ steps.drift.outputs.drift }}" = "true" ]; then
|
||||
echo "Formal conformance drift detected (informational)."
|
||||
else
|
||||
echo "Formal conformance: no drift."
|
||||
fi
|
||||
6
.github/workflows/labeler.yml
vendored
6
.github/workflows/labeler.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
@@ -200,7 +200,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
@@ -440,7 +440,7 @@ jobs:
|
||||
label-issues:
|
||||
permissions:
|
||||
issues: write
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
|
||||
4
.github/workflows/stale.yml
vendored
4
.github/workflows/stale.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: self-hosted
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1
|
||||
id: app-token
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale
|
||||
exempt-pr-labels: maintainer,no-stale
|
||||
operations-per-run: 10000
|
||||
operations-per-run: 500
|
||||
exempt-all-assignees: true
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-message: |
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"node_modules/",
|
||||
"patches/",
|
||||
"pnpm-lock.yaml/",
|
||||
"src/auto-reply/reply/export-html/",
|
||||
"Swabble/",
|
||||
"vendor/",
|
||||
],
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
"eslint-plugin-unicorn/prefer-array-find": "off",
|
||||
"eslint/no-await-in-loop": "off",
|
||||
"eslint/no-new": "off",
|
||||
"eslint/no-shadow": "off",
|
||||
"eslint/no-unmodified-loop-condition": "off",
|
||||
"oxc/no-accumulating-spread": "off",
|
||||
"oxc/no-async-endpoint-handlers": "off",
|
||||
"oxc/no-map-spread": "off",
|
||||
@@ -29,9 +27,8 @@
|
||||
"extensions/",
|
||||
"node_modules/",
|
||||
"patches/",
|
||||
"pnpm-lock.yaml",
|
||||
"pnpm-lock.yaml/",
|
||||
"skills/",
|
||||
"src/auto-reply/reply/export-html/template.js",
|
||||
"src/canvas-host/a2ui/a2ui.bundle.js",
|
||||
"Swabble/",
|
||||
"vendor/"
|
||||
|
||||
@@ -70,10 +70,6 @@
|
||||
|
||||
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
|
||||
- Formatting/linting via Oxlint and Oxfmt; run `pnpm check` before commits.
|
||||
- Never add `@ts-nocheck` and do not disable `no-explicit-any`; fix root causes and update Oxlint/Oxfmt config only when required.
|
||||
- Never share class behavior via prototype mutation (`applyPrototypeMixins`, `Object.defineProperty` on `.prototype`, or exporting `Class.prototype` for merges). Use explicit inheritance/composition (`A extends B extends C`) or helper composition so TypeScript can typecheck.
|
||||
- If this pattern is needed, stop and get explicit approval before shipping; default behavior is to split/refactor into an explicit class hierarchy and keep members strongly typed.
|
||||
- In tests, prefer per-instance stubs over prototype mutation (`SomeClass.prototype.method = ...`) unless a test explicitly documents why prototype-level patching is required.
|
||||
- Add brief code comments for tricky or non-obvious logic.
|
||||
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
|
||||
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.
|
||||
|
||||
104
CHANGELOG.md
104
CHANGELOG.md
@@ -6,102 +6,19 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- iOS/Talk: add a `Background Listening` toggle that keeps Talk Mode active while the app is backgrounded (off by default for battery safety). Thanks @zeulewan.
|
||||
- iOS/Talk: harden barge-in behavior by disabling interrupt-on-speech when output route is built-in speaker/receiver, reducing false interruptions from local TTS bleed-through. Thanks @zeulewan.
|
||||
- iOS/Talk: add a `Voice Directive Hint` toggle for Talk Mode prompts so users can disable ElevenLabs voice-switching instructions to save tokens when not needed. (#18250) Thanks @zeulewan.
|
||||
- Telegram/Agents: add inline button `style` support (`primary|success|danger`) across message tool schema, Telegram action parsing, send pipeline, and runtime prompt guidance. (#18241) Thanks @obviyus.
|
||||
- Discord: expose native `/exec` command options (host/security/ask/node) so Discord slash commands get autocomplete and structured inputs. Thanks @thewilloftheshadow.
|
||||
- Discord: allow reusable interactive components with `components.reusable=true` so buttons, selects, and forms can be used multiple times before expiring. Thanks @thewilloftheshadow.
|
||||
- Cron/Gateway: separate per-job webhook delivery (`delivery.mode = "webhook"`) from announce delivery, enforce valid HTTP(S) webhook URLs, and keep a temporary legacy `notify + cron.webhook` fallback for stored jobs. (#17901) Thanks @advaitpaliwal.
|
||||
- Discord: add per-button `allowedUsers` allowlist for interactive components to restrict who can click buttons. Thanks @thewilloftheshadow.
|
||||
- Docker: add optional `OPENCLAW_INSTALL_BROWSER` build arg to preinstall Chromium + Xvfb in the Docker image, avoiding runtime Playwright installs. (#18449)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Fix types in all tests. Typecheck the whole repository.
|
||||
- Voice-call: auto-end calls when media streams disconnect to prevent stuck active calls. (#18435) Thanks @JayMishra-source.
|
||||
- Gateway/Channels: wire `gateway.channelHealthCheckMinutes` into strict config validation, treat implicit account status as managed for health checks, and harden channel auto-restart flow (preserve restart-attempt caps across crash loops, propagate enabled/configured runtime flags, and stop pending restart backoff after manual stop). Thanks @steipete.
|
||||
- Gateway/WebChat: hard-cap `chat.history` oversized payloads by truncating high-cost fields and replacing over-budget entries with placeholders, so history fetches stay within configured byte limits and avoid chat UI freezes. (#18505)
|
||||
- UI/Usage: replace lingering undefined `var(--text-muted)` usage with `var(--muted)` in usage date-range and chart styles to keep muted text visible across themes. (#17975) Thanks @jogelin.
|
||||
- UI/Usage: preserve selected-range totals when timeline data is downsampled by bucket-aggregating timeseries points (instead of dropping intermediate points), so filtered tokens/cost stay accurate. (#17959) Thanks @jogelin.
|
||||
- UI/Sessions: refresh the sessions table only after successful deletes and preserve delete errors on cancel/failure paths, so deleted sessions disappear automatically without masking delete failures. (#18507)
|
||||
- Mattermost: harden reaction handling by requiring an explicit boolean `remove` flag and routing reaction websocket events to the reaction handler, preventing string `"true"` values from being treated as removes and avoiding double-processing of reaction events as posts. (#18608) Thanks @echo931.
|
||||
- Scripts/UI/Windows: fix `pnpm ui:*` spawn `EINVAL` failures by restoring shell-backed launch for `.cmd`/`.bat` runners, narrowing shell usage to launcher types that require it, and rejecting unsafe forwarded shell metacharacters in UI script args. (#18594)
|
||||
- Hooks/Session-memory: recover `/new` conversation summaries when session pointers are reset-path or missing `sessionFile`, and consistently prefer the newest `.jsonl.reset.*` transcript candidate for fallback extraction. (#18088)
|
||||
- Auto-reply/Sessions: prevent stale thread ID leakage into non-thread sessions so replies stay in the main DM after topic interactions. (#18528) Thanks @j2h4u.
|
||||
- Slack: restrict forwarded-attachment ingestion to explicit shared-message attachments and skip non-Slack forwarded `image_url` fetches, preventing non-forward attachment unfurls from polluting inbound agent context while preserving forwarded message handling.
|
||||
- Agents/Sessions: align session lock watchdog hold windows with run and compaction timeout budgets (plus grace), preventing valid long-running turns from being force-unlocked mid-run while still recovering hung lock owners. (#18060)
|
||||
- Cron/Heartbeat: canonicalize session-scoped reminder `sessionKey` routing and preserve explicit flat `sessionKey` cron tool inputs, preventing enqueue/wake namespace drift for session-targeted reminders. (#18637) Thanks @vignesh07.
|
||||
- OpenClawKit/iOS ChatUI: accept canonical session-key completion events for local pending runs and preserve message IDs across history refreshes, preventing stuck "thinking" state and message flicker after gateway replies. (#18165) Thanks @mbelinky.
|
||||
- iOS/Onboarding: add QR-first onboarding wizard with setup-code deep link support, pairing/auth issue guidance, and device-pair QR generation improvements for Telegram/Web/TUI fallback flows. (#18162) Thanks @mbelinky and @Marvae.
|
||||
- iOS/Gateway: stabilize connect/discovery state handling, add onboarding reset recovery in Settings, and fix iOS gateway-controller coverage for command-surface and last-connection persistence behavior. (#18164) Thanks @mbelinky.
|
||||
- iOS/Talk: harden mobile talk config handling by ignoring redacted/env-placeholder API keys, support secure local keychain override, improve accessibility motion/contrast behavior in status UI, and tighten ATS to local-network allowance. (#18163) Thanks @mbelinky.
|
||||
- iOS/Location: restore the significant location monitor implementation (service hooks + protocol surface + ATS key alignment) after merge drift so iOS builds compile again. (#18260) Thanks @ngutman.
|
||||
- Discord/Telegram: make per-account message action gates effective for both action listing and execution, and preserve top-level gate restrictions when account overrides only specify a subset of `actions` keys (account key -> base key -> default fallback). (#18494)
|
||||
- Telegram: keep DM-topic replies and draft previews in the originating private-chat topic by preserving positive `message_thread_id` values for DM threads. (#18586) Thanks @sebslight.
|
||||
- Telegram: preserve private-chat topic `message_thread_id` on outbound sends (message/sticker/poll), keep thread-not-found retry fallback, and avoid masking `chat not found` routing errors. (#18993) Thanks @obviyus.
|
||||
- Discord: prevent duplicate media delivery when the model uses the `message send` tool with media, by skipping media extraction from messaging tool results since the tool already sent the message directly. (#18270)
|
||||
- Telegram: keep draft-stream preview replies attached to the user message for `replyToMode: "all"` in groups and DMs, preserving threaded reply context from preview through finalization. (#17880) Thanks @yinghaosang.
|
||||
- Telegram: prevent streaming final replies from being overwritten by later final/error payloads, and suppress fallback tool-error warnings when a recovered assistant answer already exists after tool calls. (#17883) Thanks @Marvae and @obviyus.
|
||||
- Telegram: debounce the first draft-stream preview update (30-char threshold) and finalize short responses by editing the stop-time preview message, improving first push notifications and avoiding duplicate final sends. (#18148) Thanks @Marvae.
|
||||
- Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk.
|
||||
- Telegram: keep `streamMode: "partial"` draft previews in a single message across assistant-message/reasoning boundaries, preventing duplicate preview bubbles during partial-mode tool-call turns. (#18956) Thanks @obviyus.
|
||||
- Telegram: route non-abort slash commands on the normal chat/topic sequential lane while keeping true abort requests (`/stop`, `stop`) on the control lane, preventing command/reply race conditions from control-lane bypass. (#17899) Thanks @obviyus.
|
||||
- Telegram: ignore `<media:...>` placeholder lines when extracting `MEDIA:` tool-result paths, preventing false local-file reads and dropped replies. (#18510) Thanks @yinghaosang.
|
||||
- Telegram: skip retries when inbound media `getFile` fails with Telegram's 20MB limit and continue processing message text, avoiding dropped messages for oversized attachments. (#18531) Thanks @brandonwise.
|
||||
- Telegram: clear stored polling offsets when bot tokens change or accounts are deleted, preventing stale offsets after token rotations. (#18233)
|
||||
- Auto-reply/TTS: keep tool-result media delivery enabled in group chats and native command sessions (while still suppressing tool summary text) so `NO_REPLY` follow-ups do not drop successful TTS audio. (#17991) Thanks @zerone0x.
|
||||
- Agents/Tools: deliver tool-result media even when verbose tool output is off so media attachments are not dropped. (#16679)
|
||||
- Discord: optimize reaction notification handling to skip unnecessary message fetches in `off`/`all`/`allowlist` modes, streamline reaction routing, and improve reaction emoji formatting. (#18248) Thanks @thewilloftheshadow and @victorGPT.
|
||||
- CLI/Pairing: make `openclaw qr --remote` prefer `gateway.remote.url` over tailscale/public URL resolution and register the `openclaw clawbot qr` legacy alias path. (#18091)
|
||||
- CLI/QR: restore fail-fast validation for `openclaw qr --remote` when neither `gateway.remote.url` nor tailscale `serve`/`funnel` is configured, preventing unusable remote pairing QR flows. (#18166) Thanks @mbelinky.
|
||||
- CLI/Doctor: ensure `openclaw doctor --fix --non-interactive --yes` exits promptly after completion so one-shot automation no longer hangs. (#18502)
|
||||
- CLI/Doctor: auto-repair `dmPolicy="open"` configs missing wildcard allowlists and write channel-correct repair paths (including `channels.googlechat.dm.allowFrom`) so `openclaw doctor --fix` no longer leaves Google Chat configs invalid after attempted repair. (#18544)
|
||||
- CLI/Doctor: detect gateway service token drift when the gateway token is only provided via environment variables, keeping service repairs aligned after token rotation.
|
||||
- CLI/Status: fix `openclaw status --all` token summaries for bot-token-only channels so Mattermost/Zalo no longer show a bot+app warning. (#18527) Thanks @echo931.
|
||||
- CLI/Configure: make the `/model picker` allowlist prompt searchable with tokenized matching in `openclaw configure` so users can filter huge model lists by typing terms like `gpt-5.2 openai/`. (#19010) Thanks @bjesuiter.
|
||||
- Voice Call: add an optional stale call reaper (`staleCallReaperSeconds`) to end stuck calls when enabled. (#18437)
|
||||
- Auto-reply/Subagents: propagate group context (`groupId`, `groupChannel`, `space`) when spawning via `/subagents spawn`, matching tool-triggered subagent spawn behavior.
|
||||
- Subagents: cap announce retry loops with max attempts and expiry to prevent infinite retry spam after deferred announces. (#18444)
|
||||
- Agents/Tools/exec: add a preflight guard that detects likely shell env var injection (e.g. `$DM_JSON`, `$TMPDIR`) in Python/Node scripts before execution, preventing recurring cron failures and wasted tokens when models emit mixed shell+language source. (#12836)
|
||||
- Agents/Tools/exec: treat normal non-zero exit codes as completed and append the exit code to tool output to avoid false tool-failure warnings. (#18425)
|
||||
- Agents/Tools: make loop detection progress-aware and phased by hard-blocking known `process(action=poll|log)` no-progress loops, warning on generic identical-call repeats, warning + no-progress-blocking ping-pong alternation loops (10/20), coalescing repeated warning spam into threshold buckets (including canonical ping-pong pairs), adding a global circuit breaker at 30 no-progress repeats, and emitting structured diagnostic `tool.loop` warning/error events for loop actions. (#16808) Thanks @akramcodez and @beca-oc.
|
||||
- Agents/Hooks: preserve the `before_tool_call` wrapped-marker across abort-signal tool wrapping so the hook runs once per tool call in normal agent sessions. (#16852) Thanks @sreuter.
|
||||
- Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus.
|
||||
- Agents/Image tool: replace Anthropic-incompatible union schema with explicit `image` (single) and `images` (multi) parameters, keeping tool schemas `anyOf`/`oneOf`/`allOf`-free while preserving multi-image analysis support. (#18551, #18566) Thanks @aldoeliacim.
|
||||
- Agents/Models: probe the primary model when its auth-profile cooldown is near expiry (with per-provider throttling), so runs recover from temporary rate limits without staying on fallback models until restart. (#17478) Thanks @PlayerGhost.
|
||||
- Agents/Failover: classify provider abort stop-reason errors (`Unhandled stop reason: abort`, `stop reason: abort`, `reason: abort`) as timeout-class failures so configured model fallback chains trigger instead of surfacing raw abort failures. (#18618) Thanks @sauerdaniel.
|
||||
- Models/CLI: sync auth-profiles credentials into agent `auth.json` before registry availability checks so `openclaw models list --all` reports auth correctly for API-key/token providers, normalize provider-id aliases when bridging credentials, and skip expired token mirrors. (#18610, #18615)
|
||||
- Agents/Context: raise default total bootstrap prompt cap from `24000` to `150000` chars (keeping `bootstrapMaxChars` at `20000`), include total-cap visibility in `/context`, and mark truncation from injected-vs-raw sizes so total-cap clipping is reflected accurately.
|
||||
- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96.
|
||||
- Cron: preserve per-job schedule-error isolation in post-run maintenance recompute so malformed sibling jobs no longer abort persistence of successful runs. (#17852) Thanks @pierreeurope.
|
||||
- Gateway/Config: prevent `config.patch` object-array merges from falling back to full-array replacement when some patch entries lack `id`, so partial `agents.list` updates no longer drop unrelated agents. (#17989) Thanks @stakeswky.
|
||||
- Config/Discord: require string IDs in Discord allowlists, keep onboarding inputs string-only, and add doctor repair for numeric entries. (#18220) Thanks @thewilloftheshadow.
|
||||
- Security/Sessions: create new session transcript JSONL files with user-only (`0o600`) permissions and extend `openclaw security audit --fix` to remediate existing transcript file permissions.
|
||||
- Sessions/Maintenance: archive transcripts when pruning stale sessions, clean expired media in subdirectories, and purge `.deleted` transcript archives after the prune window to prevent disk leaks. (#18538)
|
||||
- Infra/Fetch: ensure foreign abort-signal listener cleanup never masks original fetch successes/failures, while still preventing detached-finally unhandled rejection noise in `wrapFetchWithAbortSignal`. Thanks @Jackten.
|
||||
- Heartbeat: allow suppressing tool error warning payloads during heartbeat runs via a new heartbeat config flag. (#18497) Thanks @thewilloftheshadow.
|
||||
- Heartbeat: include sender metadata (From/To/Provider) in heartbeat prompts so model context matches the delivery target. (#18532) Thanks @dinakars777.
|
||||
- Heartbeat/Telegram: strip configured `responsePrefix` before heartbeat ack detection (with boundary-safe matching) so prefixed `HEARTBEAT_OK` replies are correctly suppressed instead of leaking into DMs. (#18602)
|
||||
|
||||
## 2026.2.15
|
||||
|
||||
### Changes
|
||||
|
||||
- Discord: unlock rich interactive agent prompts with Components v2 (buttons, selects, modals, and attachment-backed file blocks) so for native interaction through Discord. Thanks @thewilloftheshadow.
|
||||
- Discord: components v2 UI + embeds passthrough + exec approval UX refinements (CV2 containers, button layout, Discord-forwarding skip). Thanks @thewilloftheshadow.
|
||||
- Plugins: expose `llm_input` and `llm_output` hook payloads so extensions can observe prompt/input context and model output usage details. (#16724) Thanks @SecondThread.
|
||||
- Subagents: nested sub-agents (sub-sub-agents) with configurable depth. Set `agents.defaults.subagents.maxSpawnDepth: 2` to allow sub-agents to spawn their own children. Includes `maxChildrenPerAgent` limit (default 5), depth-aware tool policy, and proper announce chain routing. (#14447) Thanks @tyler6204.
|
||||
- Slack/Discord/Telegram: add per-channel ack reaction overrides (account/channel-level) to support platform-specific emoji formats. (#17092) Thanks @zerone0x.
|
||||
- Cron/Gateway: add finished-run webhook delivery toggle (`notify`) and dedicated webhook auth token support (`cron.webhookToken`) for outbound cron webhook posts. (#14535) Thanks @advaitpaliwal.
|
||||
- Cron/Gateway: separate per-job webhook delivery (`delivery.mode = "webhook"`) from announce delivery, enforce valid HTTP(S) webhook URLs, and keep a temporary legacy `notify + cron.webhook` fallback for stored jobs. (#17901) Thanks @advaitpaliwal.
|
||||
- Channels: deduplicate probe/token resolution base types across core + extensions while preserving per-channel error typing. (#16986) Thanks @iyoda and @thewilloftheshadow.
|
||||
- Memory: add MMR (Maximal Marginal Relevance) re-ranking for hybrid search diversity. Configurable via `memorySearch.query.hybrid.mmr`. Thanks @rodrigouroz.
|
||||
- Memory: add opt-in temporal decay for hybrid search scoring, with configurable half-life via `memorySearch.query.hybrid.temporalDecay`. Thanks @rodrigouroz.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Discord: send initial content when creating non-forum threads so `thread-create` content is delivered. (#18117) Thanks @zerone0x.
|
||||
- Security: replace deprecated SHA-1 sandbox configuration hashing with SHA-256 for deterministic sandbox cache identity and recreation checks. Thanks @kexinoh.
|
||||
- Security/Sessions: create new session transcript JSONL files with user-only (`0o600`) permissions and extend `openclaw security audit --fix` to remediate existing transcript file permissions.
|
||||
- Security/Logging: redact Telegram bot tokens from error messages and uncaught stack traces to prevent accidental secret leakage into logs. Thanks @aether-ai-agent.
|
||||
- Sandbox/Security: block dangerous sandbox Docker config (bind mounts, host networking, unconfined seccomp/apparmor) to prevent container escape via config injection. Thanks @aether-ai-agent.
|
||||
- Sandbox: preserve array order in config hashing so order-sensitive Docker/browser settings trigger container recreation correctly. Thanks @kexinoh.
|
||||
@@ -111,12 +28,13 @@ Docs: https://docs.openclaw.ai
|
||||
- Skills/Security: restrict `download` installer `targetDir` to the per-skill tools directory to prevent arbitrary file writes. Thanks @Adam55A-code.
|
||||
- Skills/Linux: harden go installer fallback on apt-based systems by handling root/no-sudo environments safely, doing best-effort apt index refresh, and returning actionable errors instead of failing with spawn errors. (#17687) Thanks @mcrolly.
|
||||
- Web Fetch/Security: cap downloaded response body size before HTML parsing to prevent memory exhaustion from oversized or deeply nested pages. Thanks @xuemian168.
|
||||
- Infra/Fetch: ensure foreign abort-signal listener cleanup never masks original fetch successes/failures, while still preventing detached-finally unhandled rejection noise in `wrapFetchWithAbortSignal`. Thanks @Jackten.
|
||||
- Config/Gateway: make sensitive-key whitelist suffix matching case-insensitive while preserving `passwordFile` path exemptions, preventing accidental redaction of non-secret config values like `maxTokens` and IRC password-file paths. (#16042) Thanks @akramcodez.
|
||||
- Gateway/Config: prevent `config.patch` object-array merges from falling back to full-array replacement when some patch entries lack `id`, so partial `agents.list` updates no longer drop unrelated agents. (#17989) Thanks @stakeswky.
|
||||
- Dev tooling: harden git `pre-commit` hook against option injection from malicious filenames (for example `--force`), preventing accidental staging of ignored files. Thanks @mrthankyou.
|
||||
- Gateway/Agent: reject malformed `agent:`-prefixed session keys (for example, `agent:main`) in `agent` and `agent.identity.get` instead of silently resolving them to the default agent, preventing accidental cross-session routing. (#15707) Thanks @rodrigouroz.
|
||||
- Gateway/Chat: harden `chat.send` inbound message handling by rejecting null bytes, stripping unsafe control characters, and normalizing Unicode to NFC before dispatch. (#8593) Thanks @fr33d3m0n.
|
||||
- Gateway/Send: return an actionable error when `send` targets internal-only `webchat`, guiding callers to use `chat.send` or a deliverable channel. (#15703) Thanks @rodrigouroz.
|
||||
- Gateway/Commands: keep webchat command authorization on the internal `webchat` context instead of inferring another provider from channel allowlists, fixing dropped `/new`/`/status` commands in Control UI when channel allowlists are configured. (#7189) Thanks @karlisbergmanis-lv.
|
||||
- Control UI: prevent stored XSS via assistant name/avatar by removing inline script injection, serving bootstrap config as JSON, and enforcing `script-src 'self'`. Thanks @Adam55A-code.
|
||||
- Agents/Security: sanitize workspace paths before embedding into LLM prompts (strip Unicode control/format chars) to prevent instruction injection via malicious directory names. Thanks @aether-ai-agent.
|
||||
- Agents/Sandbox: clarify system prompt path guidance so sandbox `bash/exec` uses container paths (for example `/workspace`) while file tools keep host-bridge mapping, avoiding first-attempt path misses from host-only absolute paths in sandbox command execution. (#17693) Thanks @app/juniordevbot.
|
||||
@@ -125,26 +43,29 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/OpenAI: force `store=true` for direct OpenAI Responses/Codex runs to preserve multi-turn server-side conversation state, while leaving proxy/non-OpenAI endpoints unchanged. (#16803) Thanks @mark9232 and @vignesh07.
|
||||
- Memory/FTS: make `buildFtsQuery` Unicode-aware so non-ASCII queries (including CJK) produce keyword tokens instead of falling back to vector-only search. (#17672) Thanks @KinGP5471.
|
||||
- Auto-reply/Compaction: resolve `memory/YYYY-MM-DD.md` placeholders with timezone-aware runtime dates and append a `Current time:` line to memory-flush turns, preventing wrong-year memory filenames without making the system prompt time-variant. (#17603, #17633) Thanks @nicholaspapadam-wq and @vignesh07.
|
||||
- Auth/Cooldowns: auto-expire stale auth profile cooldowns when `cooldownUntil` or `disabledUntil` timestamps have passed, and reset `errorCount` so the next transient failure does not immediately escalate to a disproportionately long cooldown. Handles `cooldownUntil` and `disabledUntil` independently. (#3604) Thanks @nabbilkhan.
|
||||
- Agents: return an explicit timeout error reply when an embedded run times out before producing any payloads, preventing silent dropped turns during slow cache-refresh transitions. (#16659) Thanks @liaosvcaf and @vignesh07.
|
||||
- Group chats: always inject group chat context (name, participants, reply guidance) into the system prompt on every turn, not just the first. Prevents the model from losing awareness of which group it's in and incorrectly using the message tool to send to the same group. (#14447) Thanks @tyler6204.
|
||||
- Browser/Agents: when browser control service is unavailable, return explicit non-retry guidance (instead of "try again") so models do not loop on repeated browser tool calls until timeout. (#17673) Thanks @austenstone.
|
||||
- Subagents: use child-run-based deterministic announce idempotency keys across direct and queued delivery paths (with legacy queued-item fallback) to prevent duplicate announce retries without collapsing distinct same-millisecond announces. (#17150) Thanks @widingmarcus-cyber.
|
||||
- Subagents/Models: preserve `agents.defaults.model.fallbacks` when subagent sessions carry a model override, so subagent runs fail over to configured fallback models instead of retrying only the overridden primary model.
|
||||
- Agents/Tools: scope the `message` tool schema to the active channel so Telegram uses `buttons` and Discord uses `components`. (#18215) Thanks @obviyus.
|
||||
- Agents/Models: probe the primary model when its auth-profile cooldown is near expiry (with per-provider throttling), so runs recover from temporary rate limits without staying on fallback models until restart. (#17478) Thanks @PlayerGhost.
|
||||
- Telegram: omit `message_thread_id` for DM sends/draft previews and keep forum-topic handling (`id=1` general omitted, non-general kept), preventing DM failures with `400 Bad Request: message thread not found`. (#10942) Thanks @garnetlyx.
|
||||
- Telegram: replace inbound `<media:audio>` placeholder with successful preflight voice transcript in message body context, preventing placeholder-only prompt bodies for mention-gated voice messages. (#16789) Thanks @Limitless2023.
|
||||
- Telegram: retry inbound media `getFile` calls (3 attempts with backoff) and gracefully fall back to placeholder-only processing when retries fail, preventing dropped voice/media messages on transient Telegram network errors. (#16154) Thanks @yinghaosang.
|
||||
- Telegram: finalize streaming preview replies in place instead of sending a second final message, preventing duplicate Telegram assistant outputs at stream completion. (#17218) Thanks @obviyus.
|
||||
- Telegram: keep draft-stream preview replies attached to the user message for `replyToMode: "all"` in groups and DMs, preserving threaded reply context from preview through finalization. (#17880) Thanks @yinghaosang.
|
||||
- Telegram: disable block streaming when `channels.telegram.streamMode` is `off`, preventing newline/content-block replies from splitting into multiple messages. (#17679) Thanks @saivarunk.
|
||||
- Telegram: route non-abort slash commands on the normal chat/topic sequential lane while keeping true abort requests (`/stop`, `stop`) on the control lane, preventing command/reply race conditions from control-lane bypass. (#17899) Thanks @obviyus.
|
||||
- Telegram: prevent streaming final replies from being overwritten by later final/error payloads, and suppress fallback tool-error warnings when a recovered assistant answer already exists after tool calls. (#17883) Thanks @Marvae and @obviyus.
|
||||
- Discord: preserve channel session continuity when runtime payloads omit `message.channelId` by falling back to event/raw `channel_id` values for routing/session keys, so same-channel messages keep history across turns/restarts. Also align diagnostics so active Discord runs no longer appear as `sessionKey=unknown`. (#17622) Thanks @shakkernerd.
|
||||
- Discord: dedupe native skill commands by skill name in multi-agent setups to prevent duplicated slash commands with `_2` suffixes. (#17365) Thanks @seewhyme.
|
||||
- Discord: ensure role allowlist matching uses raw role IDs for message routing authorization. Thanks @xinhuagu.
|
||||
- Discord: skip text-based exec approval forwarding in favor of Discord's component-based approval UI. Thanks @thewilloftheshadow.
|
||||
- Web UI/Agents: hide `BOOTSTRAP.md` in the Agents Files list after onboarding is completed, avoiding confusing missing-file warnings for completed workspaces. (#17491) Thanks @gumadeiras.
|
||||
- Memory/QMD: scope managed collection names per agent and precreate glob-backed collection directories before registration, preventing cross-agent collection clobbering and startup ENOENT failures in fresh workspaces. (#17194) Thanks @jonathanadams96.
|
||||
- Gateway/Memory: initialize QMD startup sync for every configured agent (not just the default agent), so `memory.qmd.update.onBoot` is effective across multi-agent setups. (#17663) Thanks @HenryLoenwind.
|
||||
- Auto-reply/WhatsApp/TUI/Web: when a final assistant message is `NO_REPLY` and a messaging tool send succeeded, mirror the delivered messaging-tool text into session-visible assistant output so TUI/Web no longer show `NO_REPLY` placeholders. (#7010) Thanks @Morrowind-Xie.
|
||||
- Auto-reply/TTS: keep tool-result media delivery enabled in group chats and native command sessions (while still suppressing tool summary text) so `NO_REPLY` follow-ups do not drop successful TTS audio. (#17991) Thanks @zerone0x.
|
||||
- Cron: infer `payload.kind="agentTurn"` for model-only `cron.update` payload patches, so partial agent-turn updates do not fail validation when `kind` is omitted. (#15664) Thanks @rodrigouroz.
|
||||
- Cron: preserve per-job schedule-error isolation in post-run maintenance recompute so malformed sibling jobs no longer abort persistence of successful runs. (#17852) Thanks @pierreeurope.
|
||||
- TUI: make searchable-select filtering and highlight rendering ANSI-aware so queries ignore hidden escape codes and no longer corrupt ANSI styling sequences during match highlighting. (#4519) Thanks @bee4come.
|
||||
- TUI/Windows: coalesce rapid single-line submit bursts in Git Bash into one multiline message as a fallback when bracketed paste is unavailable, preventing pasted multiline text from being split into multiple sends. (#4986) Thanks @adamkane.
|
||||
- TUI: suppress false `(no output)` placeholders for non-local empty final events during concurrent runs, preventing external-channel replies from showing empty assistant bubbles while a local run is still streaming. (#5782) Thanks @LagWizard and @vignesh07.
|
||||
@@ -485,7 +406,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Browser: add Chrome launch flag `--disable-blink-features=AutomationControlled` to reduce `navigator.webdriver` automation detection issues on reCAPTCHA-protected sites. (#10735) Thanks @Milofax.
|
||||
- Heartbeat: filter noise-only system events so scheduled reminder notifications do not fire when cron runs carry only heartbeat markers. (#13317) Thanks @pvtclawn.
|
||||
- Signal: render mention placeholders as `@uuid`/`@phone` so mention gating and Clawdbot targeting work. (#2013) Thanks @alexgleason.
|
||||
- Agents/Reminders: guard reminder promises by appending a note when no `cron.add` succeeded in the turn, so users know nothing was scheduled. (#18588) Thanks @vignesh07.
|
||||
- Discord: omit empty content fields for media-only messages while preserving caption whitespace. (#9507) Thanks @leszekszpunar.
|
||||
- Onboarding/Providers: add Z.AI endpoint-specific auth choices (`zai-coding-global`, `zai-coding-cn`, `zai-global`, `zai-cn`) and expand default Z.AI model wiring. (#13456) Thanks @tomsun28.
|
||||
- Onboarding/Providers: update MiniMax API default/recommended models from M2.1 to M2.5, add M2.5/M2.5-Lightning model entries, and include `minimax-m2.5` in modern model filtering. (#14865) Thanks @adao-max.
|
||||
@@ -529,7 +449,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Commands: add `commands.allowFrom` config for separate command authorization, allowing operators to restrict slash commands to specific users while keeping chat open to others. (#12430) Thanks @thewilloftheshadow.
|
||||
- Docker: add ClawDock shell helpers for Docker workflows. (#12817) Thanks @Olshansk.
|
||||
- Gateway: periodic channel health monitor auto-restarts stuck, crashed, or silently-stopped channels. Configurable via `gateway.channelHealthCheckMinutes` (default: 5, set to 0 to disable). (#7053, #4302)
|
||||
- iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky.
|
||||
- Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204.
|
||||
- Channels: IRC first-class channel support. (#11482) Thanks @vignesh07.
|
||||
@@ -593,7 +512,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Thinking: allow xhigh for `github-copilot/gpt-5.2-codex` and `github-copilot/gpt-5.2`. (#11646) Thanks @LatencyTDH.
|
||||
- Thinking: honor `/think off` for reasoning-capable models. (#9564) Thanks @liuy.
|
||||
- Discord: support forum/media thread-create starter messages, wire `message thread create --message`, and harden routing. (#10062) Thanks @jarvis89757.
|
||||
- Discord: download attachments from forwarded messages. (#17049) Thanks @pip-nomel, @thewilloftheshadow.
|
||||
- Paths: structurally resolve `OPENCLAW_HOME`-derived home paths and fix Windows drive-letter handling in tool meta shortening. (#12125) Thanks @mcaxtr.
|
||||
- Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
|
||||
- Memory: disable async batch embeddings by default for memory indexing (opt-in via `agents.defaults.memorySearch.remote.batch.enabled`). (#13069) Thanks @mcinteerj.
|
||||
|
||||
13
Dockerfile
13
Dockerfile
@@ -23,19 +23,6 @@ COPY scripts ./scripts
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Optionally install Chromium and Xvfb for browser automation.
|
||||
# Build with: docker build --build-arg OPENCLAW_INSTALL_BROWSER=1 ...
|
||||
# Adds ~300MB but eliminates the 60-90s Playwright install on every container start.
|
||||
# Must run after pnpm install so playwright-core is available in node_modules.
|
||||
ARG OPENCLAW_INSTALL_BROWSER=""
|
||||
RUN if [ -n "$OPENCLAW_INSTALL_BROWSER" ]; then \
|
||||
apt-get update && \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xvfb && \
|
||||
node /app/node_modules/playwright-core/cli.js install --with-deps chromium && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*; \
|
||||
fi
|
||||
|
||||
COPY . .
|
||||
RUN pnpm build
|
||||
# Force pnpm for UI build (Bun may fail on ARM/Synology architectures)
|
||||
|
||||
@@ -267,7 +267,6 @@ ClawHub is a minimal skill registry. With ClawHub enabled, the agent can search
|
||||
Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only):
|
||||
|
||||
- `/status` — compact session status (model + tokens, cost when available)
|
||||
- `/mesh <goal>` — auto-plan + run a multi-step workflow (`/mesh plan|run|status|retry` available)
|
||||
- `/new` or `/reset` — reset the session
|
||||
- `/compact` — compact session context (summary)
|
||||
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
|
||||
@@ -304,7 +303,6 @@ Runbook: [iOS connect](https://docs.openclaw.ai/platforms/ios).
|
||||
- Pairs via the same Bridge + pairing flow as iOS.
|
||||
- Exposes Canvas, Camera, and Screen capture commands.
|
||||
- Runbook: [Android connect](https://docs.openclaw.ai/platforms/android).
|
||||
- Install: [OpenClaw for Android](https://github.com/irtiq7/OpenClaw-Android).
|
||||
|
||||
## Agent workspace + skills
|
||||
|
||||
@@ -548,5 +546,4 @@ Thanks to all clawtributors:
|
||||
<a href="https://github.com/0xJonHoldsCrypto"><img src="https://avatars.githubusercontent.com/u/81202085?v=4&s=48" width="48" height="48" alt="0xJonHoldsCrypto" title="0xJonHoldsCrypto"/></a> <a href="https://github.com/aaronn"><img src="https://avatars.githubusercontent.com/u/1653630?v=4&s=48" width="48" height="48" alt="aaronn" title="aaronn"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/atalovesyou"><img src="https://avatars.githubusercontent.com/u/3534502?v=4&s=48" width="48" height="48" alt="atalovesyou" title="atalovesyou"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/jiulingyun"><img src="https://avatars.githubusercontent.com/u/126459548?v=4&s=48" width="48" height="48" alt="jiulingyun" title="jiulingyun"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
|
||||
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a>
|
||||
<a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||
<a href="https://github.com/AkashKobal"><img src="https://avatars.githubusercontent.com/u/98216083?v=4" width="48" height="48" alt="Akash Kobal" title="Akash Kobal"/></a>
|
||||
</p>
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
<title>2026.2.15</title>
|
||||
<pubDate>Mon, 16 Feb 2026 05:04:34 +0100</pubDate>
|
||||
<link>https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml</link>
|
||||
<sparkle:version>202602150</sparkle:version>
|
||||
<sparkle:version>11213</sparkle:version>
|
||||
<sparkle:shortVersionString>2026.2.15</sparkle:shortVersionString>
|
||||
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
|
||||
<description><![CDATA[<h2>OpenClaw 2026.2.15</h2>
|
||||
|
||||
@@ -2,10 +2,8 @@ import OpenClawChatUI
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
||||
private static let logger = Logger(subsystem: "ai.openclaw", category: "ios.chat.transport")
|
||||
private let gateway: GatewayNodeSession
|
||||
|
||||
init(gateway: GatewayNodeSession) {
|
||||
@@ -35,8 +33,10 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
||||
}
|
||||
|
||||
func setActiveSessionKey(_ sessionKey: String) async throws {
|
||||
// Operator clients receive chat events without node-style subscriptions.
|
||||
// (chat.subscribe is a node event, not an operator RPC method.)
|
||||
struct Subscribe: Codable { var sessionKey: String }
|
||||
let data = try JSONEncoder().encode(Subscribe(sessionKey: sessionKey))
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
await self.gateway.sendEvent(event: "chat.subscribe", payloadJSON: json)
|
||||
}
|
||||
|
||||
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
|
||||
@@ -54,7 +54,6 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
||||
idempotencyKey: String,
|
||||
attachments: [OpenClawChatAttachmentPayload]) async throws -> OpenClawChatSendResponse
|
||||
{
|
||||
Self.logger.info("chat.send start sessionKey=\(sessionKey, privacy: .public) len=\(message.count, privacy: .public) attachments=\(attachments.count, privacy: .public)")
|
||||
struct Params: Codable {
|
||||
var sessionKey: String
|
||||
var message: String
|
||||
@@ -73,15 +72,8 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable {
|
||||
idempotencyKey: idempotencyKey)
|
||||
let data = try JSONEncoder().encode(params)
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
do {
|
||||
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
|
||||
let decoded = try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
|
||||
Self.logger.info("chat.send ok runId=\(decoded.runId, privacy: .public)")
|
||||
return decoded
|
||||
} catch {
|
||||
Self.logger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
|
||||
throw error
|
||||
}
|
||||
let res = try await self.gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 35)
|
||||
return try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
|
||||
}
|
||||
|
||||
func requestHealth(timeoutMs: Int) async throws -> Bool {
|
||||
|
||||
@@ -72,55 +72,32 @@ final class GatewayConnectionController {
|
||||
}
|
||||
}
|
||||
|
||||
func allowAutoConnectAgain() {
|
||||
self.didAutoConnect = false
|
||||
self.maybeAutoConnect()
|
||||
}
|
||||
|
||||
func restartDiscovery() {
|
||||
self.discovery.stop()
|
||||
self.didAutoConnect = false
|
||||
self.discovery.start()
|
||||
self.updateFromDiscovery()
|
||||
}
|
||||
|
||||
|
||||
/// Returns `nil` when a connect attempt was started, otherwise returns a user-facing error.
|
||||
func connectWithDiagnostics(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String? {
|
||||
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||
await self.connectDiscoveredGateway(gateway)
|
||||
}
|
||||
|
||||
private func connectDiscoveredGateway(
|
||||
_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async -> String?
|
||||
_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async
|
||||
{
|
||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if instanceId.isEmpty {
|
||||
return "Missing instanceId (node.instanceId). Try restarting the app."
|
||||
}
|
||||
let token = GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||
let password = GatewaySettingsStore.loadGatewayPassword(instanceId: instanceId)
|
||||
|
||||
// Resolve the service endpoint (SRV/A/AAAA). TXT is unauthenticated; do not route via TXT.
|
||||
guard let target = await self.resolveServiceEndpoint(gateway.endpoint) else {
|
||||
return "Failed to resolve the discovered gateway endpoint."
|
||||
}
|
||||
guard let target = await self.resolveServiceEndpoint(gateway.endpoint) else { return }
|
||||
|
||||
let stableID = gateway.stableID
|
||||
// Discovery is a LAN operation; refuse unauthenticated plaintext connects.
|
||||
let tlsRequired = true
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
|
||||
guard gateway.tlsEnabled || stored != nil else {
|
||||
return "Discovered gateway is missing TLS and no trusted fingerprint is stored."
|
||||
}
|
||||
guard gateway.tlsEnabled || stored != nil else { return }
|
||||
|
||||
if tlsRequired, stored == nil {
|
||||
guard let url = self.buildGatewayURL(host: target.host, port: target.port, useTLS: true)
|
||||
else { return "Failed to build TLS URL for trust verification." }
|
||||
guard let fp = await self.probeTLSFingerprint(url: url) else {
|
||||
return "Failed to read TLS fingerprint from discovered gateway."
|
||||
}
|
||||
else { return }
|
||||
guard let fp = await self.probeTLSFingerprint(url: url) else { return }
|
||||
self.pendingTrustConnect = (url: url, stableID: stableID, isManual: false)
|
||||
self.pendingTrustPrompt = TrustPrompt(
|
||||
stableID: stableID,
|
||||
@@ -130,7 +107,7 @@ final class GatewayConnectionController {
|
||||
fingerprintSha256: fp,
|
||||
isManual: false)
|
||||
self.appModel?.gatewayStatusText = "Verify gateway TLS fingerprint"
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
let tlsParams = stored.map { fp in
|
||||
@@ -141,7 +118,7 @@ final class GatewayConnectionController {
|
||||
host: target.host,
|
||||
port: target.port,
|
||||
useTLS: tlsParams?.required == true)
|
||||
else { return "Failed to build discovered gateway URL." }
|
||||
else { return }
|
||||
GatewaySettingsStore.saveLastGatewayConnectionDiscovered(stableID: stableID, useTLS: true)
|
||||
self.didAutoConnect = true
|
||||
self.startAutoConnect(
|
||||
@@ -150,11 +127,6 @@ final class GatewayConnectionController {
|
||||
tls: tlsParams,
|
||||
token: token,
|
||||
password: password)
|
||||
return nil
|
||||
}
|
||||
|
||||
func connect(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||
_ = await self.connectWithDiagnostics(gateway)
|
||||
}
|
||||
|
||||
func connectManual(host: String, port: Int, useTLS: Bool) async {
|
||||
@@ -518,125 +490,6 @@ final class GatewayConnectionController {
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
|
||||
switch endpoint {
|
||||
case let .hostPort(host, port):
|
||||
return (host: host.debugDescription, port: Int(port.rawValue))
|
||||
case let .service(name, type, domain, _):
|
||||
return await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func resolveBonjourServiceToHostPort(
|
||||
name: String,
|
||||
type: String,
|
||||
domain: String,
|
||||
timeoutSeconds: TimeInterval = 3.0
|
||||
) async -> (host: String, port: Int)? {
|
||||
// NetService callbacks are delivered via a run loop. If we resolve from a thread without one,
|
||||
// we can end up never receiving callbacks, which in turn leaks the continuation and leaves
|
||||
// the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always
|
||||
// resume the continuation exactly once (timeout/cancel safe).
|
||||
@MainActor
|
||||
final class Resolver: NSObject, @preconcurrency NetServiceDelegate {
|
||||
private var cont: CheckedContinuation<(host: String, port: Int)?, Never>?
|
||||
private let service: NetService
|
||||
private var timeoutTask: Task<Void, Never>?
|
||||
private var finished = false
|
||||
|
||||
init(cont: CheckedContinuation<(host: String, port: Int)?, Never>, service: NetService) {
|
||||
self.cont = cont
|
||||
self.service = service
|
||||
super.init()
|
||||
}
|
||||
|
||||
func start(timeoutSeconds: TimeInterval) {
|
||||
self.service.delegate = self
|
||||
self.service.schedule(in: .main, forMode: .default)
|
||||
|
||||
// NetService has its own timeout, but we keep a manual one as a backstop in case
|
||||
// callbacks never arrive (e.g. local network permission issues).
|
||||
self.timeoutTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
let ns = UInt64(max(0.1, timeoutSeconds) * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: ns)
|
||||
self.finish(nil)
|
||||
}
|
||||
|
||||
self.service.resolve(withTimeout: timeoutSeconds)
|
||||
}
|
||||
|
||||
func netServiceDidResolveAddress(_ sender: NetService) {
|
||||
self.finish(Self.extractHostPort(sender))
|
||||
}
|
||||
|
||||
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
|
||||
_ = errorDict // currently best-effort; callers surface a generic failure
|
||||
self.finish(nil)
|
||||
}
|
||||
|
||||
private func finish(_ result: (host: String, port: Int)?) {
|
||||
guard !self.finished else { return }
|
||||
self.finished = true
|
||||
|
||||
self.timeoutTask?.cancel()
|
||||
self.timeoutTask = nil
|
||||
|
||||
self.service.stop()
|
||||
self.service.remove(from: .main, forMode: .default)
|
||||
|
||||
let c = self.cont
|
||||
self.cont = nil
|
||||
c?.resume(returning: result)
|
||||
}
|
||||
|
||||
private static func extractHostPort(_ svc: NetService) -> (host: String, port: Int)? {
|
||||
let port = svc.port
|
||||
|
||||
if let host = svc.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty {
|
||||
return (host: host, port: port)
|
||||
}
|
||||
|
||||
guard let addrs = svc.addresses else { return nil }
|
||||
for addrData in addrs {
|
||||
let host = addrData.withUnsafeBytes { ptr -> String? in
|
||||
guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil }
|
||||
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
|
||||
|
||||
let rc = getnameinfo(
|
||||
base.assumingMemoryBound(to: sockaddr.self),
|
||||
socklen_t(ptr.count),
|
||||
&buffer,
|
||||
socklen_t(buffer.count),
|
||||
nil,
|
||||
0,
|
||||
NI_NUMERICHOST)
|
||||
guard rc == 0 else { return nil }
|
||||
return String(cString: buffer)
|
||||
}
|
||||
|
||||
if let host, !host.isEmpty {
|
||||
return (host: host, port: port)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return await withCheckedContinuation { cont in
|
||||
Task { @MainActor in
|
||||
let service = NetService(domain: domain, type: type, name: name)
|
||||
let resolver = Resolver(cont: cont, service: service)
|
||||
// Keep the resolver alive for the lifetime of the NetService resolve.
|
||||
objc_setAssociatedObject(service, "resolver", resolver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
||||
resolver.start(timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func buildGatewayURL(host: String, port: Int, useTLS: Bool) -> URL? {
|
||||
let scheme = useTLS ? "wss" : "ws"
|
||||
var components = URLComponents()
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum GatewayConnectionIssue: Equatable {
|
||||
case none
|
||||
case tokenMissing
|
||||
case unauthorized
|
||||
case pairingRequired(requestId: String?)
|
||||
case network
|
||||
case unknown(String)
|
||||
|
||||
var requestId: String? {
|
||||
if case let .pairingRequired(requestId) = self {
|
||||
return requestId
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var needsAuthToken: Bool {
|
||||
switch self {
|
||||
case .tokenMissing, .unauthorized:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var needsPairing: Bool {
|
||||
if case .pairingRequired = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
static func detect(from statusText: String) -> Self {
|
||||
let trimmed = statusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return .none }
|
||||
let lower = trimmed.lowercased()
|
||||
|
||||
if lower.contains("pairing required") || lower.contains("not_paired") || lower.contains("not paired") {
|
||||
return .pairingRequired(requestId: self.extractRequestId(from: trimmed))
|
||||
}
|
||||
if lower.contains("gateway token missing") {
|
||||
return .tokenMissing
|
||||
}
|
||||
if lower.contains("unauthorized") {
|
||||
return .unauthorized
|
||||
}
|
||||
if lower.contains("connection refused") ||
|
||||
lower.contains("timed out") ||
|
||||
lower.contains("network is unreachable") ||
|
||||
lower.contains("cannot find host") ||
|
||||
lower.contains("could not connect")
|
||||
{
|
||||
return .network
|
||||
}
|
||||
if lower.hasPrefix("gateway error:") {
|
||||
return .unknown(trimmed)
|
||||
}
|
||||
return .none
|
||||
}
|
||||
|
||||
private static func extractRequestId(from statusText: String) -> String? {
|
||||
let marker = "requestId:"
|
||||
guard let range = statusText.range(of: marker) else { return nil }
|
||||
let suffix = statusText[range.upperBound...]
|
||||
let trimmed = suffix.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let end = trimmed.firstIndex(where: { ch in
|
||||
ch == ")" || ch.isWhitespace || ch == "," || ch == ";"
|
||||
}) ?? trimmed.endIndex
|
||||
let id = String(trimmed[..<end]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return id.isEmpty ? nil : id
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct GatewayQuickSetupSheet: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
|
||||
@State private var connecting: Bool = false
|
||||
@State private var connectError: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Connect to a Gateway?")
|
||||
.font(.title2.bold())
|
||||
|
||||
if let candidate = self.bestCandidate {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(verbatim: candidate.name)
|
||||
.font(.headline)
|
||||
Text(verbatim: candidate.debugID)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
// Use verbatim strings so Bonjour-provided values can't be interpreted as
|
||||
// localized format strings (which can crash with Objective-C exceptions).
|
||||
Text(verbatim: "Discovery: \(self.gatewayController.discoveryStatusText)")
|
||||
Text(verbatim: "Status: \(self.appModel.gatewayStatusText)")
|
||||
Text(verbatim: "Node: \(self.appModel.nodeStatusText)")
|
||||
Text(verbatim: "Operator: \(self.appModel.operatorStatusText)")
|
||||
}
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(.thinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||
|
||||
Button {
|
||||
self.connectError = nil
|
||||
self.connecting = true
|
||||
Task {
|
||||
let err = await self.gatewayController.connectWithDiagnostics(candidate)
|
||||
await MainActor.run {
|
||||
self.connecting = false
|
||||
self.connectError = err
|
||||
// If we kicked off a connect, leave the sheet up so the user can see status evolve.
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Group {
|
||||
if self.connecting {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView().progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(self.connecting)
|
||||
|
||||
if let connectError {
|
||||
Text(connectError)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
Button {
|
||||
self.dismiss()
|
||||
} label: {
|
||||
Text("Not now")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(self.connecting)
|
||||
|
||||
Toggle("Don’t show this again", isOn: self.$quickSetupDismissed)
|
||||
.padding(.top, 4)
|
||||
} else {
|
||||
Text("No gateways found yet. Make sure your gateway is running and Bonjour discovery is enabled.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.navigationTitle("Quick Setup")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button {
|
||||
self.quickSetupDismissed = true
|
||||
self.dismiss()
|
||||
} label: {
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var bestCandidate: GatewayDiscoveryModel.DiscoveredGateway? {
|
||||
// Prefer whatever discovery says is first; the list is already name-sorted.
|
||||
self.gatewayController.gateways.first
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import os
|
||||
enum GatewaySettingsStore {
|
||||
private static let gatewayService = "ai.openclaw.gateway"
|
||||
private static let nodeService = "ai.openclaw.node"
|
||||
private static let talkService = "ai.openclaw.talk"
|
||||
|
||||
private static let instanceIdDefaultsKey = "node.instanceId"
|
||||
private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID"
|
||||
@@ -25,7 +24,6 @@ enum GatewaySettingsStore {
|
||||
private static let instanceIdAccount = "instanceId"
|
||||
private static let preferredGatewayStableIDAccount = "preferredStableID"
|
||||
private static let lastDiscoveredGatewayStableIDAccount = "lastDiscoveredStableID"
|
||||
private static let talkElevenLabsApiKeyAccount = "elevenlabs.apiKey"
|
||||
|
||||
static func bootstrapPersistence() {
|
||||
self.ensureStableInstanceID()
|
||||
@@ -145,27 +143,6 @@ enum GatewaySettingsStore {
|
||||
case discovered
|
||||
}
|
||||
|
||||
static func loadTalkElevenLabsApiKey() -> String? {
|
||||
let value = KeychainStore.loadString(
|
||||
service: self.talkService,
|
||||
account: self.talkElevenLabsApiKeyAccount)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if value?.isEmpty == false { return value }
|
||||
return nil
|
||||
}
|
||||
|
||||
static func saveTalkElevenLabsApiKey(_ apiKey: String?) {
|
||||
let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if trimmed.isEmpty {
|
||||
_ = KeychainStore.delete(service: self.talkService, account: self.talkElevenLabsApiKeyAccount)
|
||||
return
|
||||
}
|
||||
_ = KeychainStore.saveString(
|
||||
trimmed,
|
||||
service: self.talkService,
|
||||
account: self.talkElevenLabsApiKeyAccount)
|
||||
}
|
||||
|
||||
static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults.set(LastGatewayKind.manual.rawValue, forKey: self.lastGatewayKindDefaultsKey)
|
||||
@@ -207,25 +184,6 @@ enum GatewaySettingsStore {
|
||||
return .manual(host: host, port: port, useTLS: useTLS, stableID: stableID)
|
||||
}
|
||||
|
||||
static func clearLastGatewayConnection(defaults: UserDefaults = .standard) {
|
||||
defaults.removeObject(forKey: self.lastGatewayKindDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayHostDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayPortDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayTlsDefaultsKey)
|
||||
defaults.removeObject(forKey: self.lastGatewayStableIDDefaultsKey)
|
||||
}
|
||||
|
||||
static func deleteGatewayCredentials(instanceId: String) {
|
||||
let trimmed = instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
_ = KeychainStore.delete(
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayTokenAccount(instanceId: trimmed))
|
||||
_ = KeychainStore.delete(
|
||||
service: self.gatewayService,
|
||||
account: self.gatewayPasswordAccount(instanceId: trimmed))
|
||||
}
|
||||
|
||||
static func loadGatewayClientIdOverride(stableID: String) -> String? {
|
||||
let trimmedID = stableID.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedID.isEmpty else { return nil }
|
||||
|
||||
@@ -6,10 +6,10 @@ struct GatewayTrustPromptAlert: ViewModifier {
|
||||
private var promptBinding: Binding<GatewayConnectionController.TrustPrompt?> {
|
||||
Binding(
|
||||
get: { self.gatewayController.pendingTrustPrompt },
|
||||
set: { _ in
|
||||
// Keep pending trust state until explicit user action.
|
||||
// `alert(item:)` may set the binding to nil during dismissal, which can race with
|
||||
// the button handler and cause accept to no-op.
|
||||
set: { newValue in
|
||||
if newValue == nil {
|
||||
self.gatewayController.clearPendingTrustPrompt()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -39,3 +39,4 @@ extension View {
|
||||
self.modifier(GatewayTrustPromptAlert())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,15 +17,15 @@
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.16</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260216</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2026.2.16</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>20260216</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
|
||||
@@ -12,10 +12,6 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
private let manager = CLLocationManager()
|
||||
private var authContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
|
||||
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
|
||||
private var updatesContinuation: AsyncStream<CLLocation>.Continuation?
|
||||
private var isStreaming = false
|
||||
private var significantLocationCallback: (@Sendable (CLLocation) -> Void)?
|
||||
private var isMonitoringSignificantChanges = false
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
@@ -108,56 +104,6 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
func startLocationUpdates(
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
significantChangesOnly: Bool) -> AsyncStream<CLLocation>
|
||||
{
|
||||
self.stopLocationUpdates()
|
||||
|
||||
self.manager.desiredAccuracy = Self.accuracyValue(desiredAccuracy)
|
||||
self.manager.pausesLocationUpdatesAutomatically = true
|
||||
self.manager.allowsBackgroundLocationUpdates = true
|
||||
|
||||
self.isStreaming = true
|
||||
if significantChangesOnly {
|
||||
self.manager.startMonitoringSignificantLocationChanges()
|
||||
} else {
|
||||
self.manager.startUpdatingLocation()
|
||||
}
|
||||
|
||||
return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
|
||||
self.updatesContinuation = continuation
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
Task { @MainActor in
|
||||
self.stopLocationUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopLocationUpdates() {
|
||||
guard self.isStreaming else { return }
|
||||
self.isStreaming = false
|
||||
self.manager.stopUpdatingLocation()
|
||||
self.manager.stopMonitoringSignificantLocationChanges()
|
||||
self.updatesContinuation?.finish()
|
||||
self.updatesContinuation = nil
|
||||
}
|
||||
|
||||
func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void) {
|
||||
self.significantLocationCallback = onUpdate
|
||||
guard !self.isMonitoringSignificantChanges else { return }
|
||||
self.isMonitoringSignificantChanges = true
|
||||
self.manager.startMonitoringSignificantLocationChanges()
|
||||
}
|
||||
|
||||
func stopMonitoringSignificantLocationChanges() {
|
||||
guard self.isMonitoringSignificantChanges else { return }
|
||||
self.isMonitoringSignificantChanges = false
|
||||
self.significantLocationCallback = nil
|
||||
self.manager.stopMonitoringSignificantLocationChanges()
|
||||
}
|
||||
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor in
|
||||
@@ -171,22 +117,12 @@ final class LocationService: NSObject, CLLocationManagerDelegate {
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
let locs = locations
|
||||
Task { @MainActor in
|
||||
// Resolve the one-shot continuation first (if any).
|
||||
if let cont = self.locationContinuation {
|
||||
self.locationContinuation = nil
|
||||
if let latest = locs.last {
|
||||
cont.resume(returning: latest)
|
||||
} else {
|
||||
cont.resume(throwing: Error.unavailable)
|
||||
}
|
||||
// Don't return — also forward to significant-change callback below
|
||||
// so both consumers receive updates when both are active.
|
||||
}
|
||||
if let callback = self.significantLocationCallback, let latest = locs.last {
|
||||
callback(latest)
|
||||
}
|
||||
if let latest = locs.last, let updates = self.updatesContinuation {
|
||||
updates.yield(latest)
|
||||
guard let cont = self.locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
if let latest = locs.last {
|
||||
cont.resume(returning: latest)
|
||||
} else {
|
||||
cont.resume(throwing: Error.unavailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import CoreLocation
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
/// Monitors significant location changes and pushes `location.update`
|
||||
/// events to the gateway so the severance hook can determine whether
|
||||
/// the user is at their configured work location.
|
||||
@MainActor
|
||||
enum SignificantLocationMonitor {
|
||||
static func startIfNeeded(
|
||||
locationService: any LocationServicing,
|
||||
locationMode: OpenClawLocationMode,
|
||||
gateway: GatewayNodeSession
|
||||
) {
|
||||
guard locationMode == .always else { return }
|
||||
let status = locationService.authorizationStatus()
|
||||
guard status == .authorizedAlways else { return }
|
||||
locationService.startMonitoringSignificantLocationChanges { location in
|
||||
struct Payload: Codable {
|
||||
var lat: Double
|
||||
var lon: Double
|
||||
var accuracyMeters: Double
|
||||
var source: String?
|
||||
}
|
||||
let payload = Payload(
|
||||
lat: location.coordinate.latitude,
|
||||
lon: location.coordinate.longitude,
|
||||
accuracyMeters: location.horizontalAccuracy,
|
||||
source: "ios-significant-location")
|
||||
guard let data = try? JSONEncoder().encode(payload),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else { return }
|
||||
Task { @MainActor in
|
||||
await gateway.sendEvent(event: "location.update", payloadJSON: json)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import UserNotifications
|
||||
private struct NotificationCallError: Error, Sendable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
// Ensures notification requests return promptly even if the system prompt blocks.
|
||||
private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
@@ -36,6 +37,7 @@ private final class NotificationInvokeLatch<T: Sendable>: @unchecked Sendable {
|
||||
cont?.resume(returning: response)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class NodeAppModel {
|
||||
@@ -51,17 +53,10 @@ final class NodeAppModel {
|
||||
private let camera: any CameraServicing
|
||||
private let screenRecorder: any ScreenRecordingServicing
|
||||
var gatewayStatusText: String = "Offline"
|
||||
var nodeStatusText: String = "Offline"
|
||||
var operatorStatusText: String = "Offline"
|
||||
var gatewayServerName: String?
|
||||
var gatewayRemoteAddress: String?
|
||||
var connectedGatewayID: String?
|
||||
var gatewayAutoReconnectEnabled: Bool = true
|
||||
// When the gateway requires pairing approval, we pause reconnect churn and show a stable UX.
|
||||
// Reconnect loops (both our own and the underlying WebSocket watchdog) can otherwise generate
|
||||
// multiple pending requests and cause the onboarding UI to "flip-flop".
|
||||
var gatewayPairingPaused: Bool = false
|
||||
var gatewayPairingRequestId: String?
|
||||
var seamColorHex: String?
|
||||
private var mainSessionBaseKey: String = "main"
|
||||
var selectedAgentId: String?
|
||||
@@ -114,7 +109,6 @@ final class NodeAppModel {
|
||||
private var talkVoiceWakeSuspended = false
|
||||
private var backgroundVoiceWakeSuspended = false
|
||||
private var backgroundTalkSuspended = false
|
||||
private var backgroundTalkKeptActive = false
|
||||
private var backgroundedAt: Date?
|
||||
private var reconnectAfterBackgroundArmed = false
|
||||
|
||||
@@ -270,18 +264,15 @@ final class NodeAppModel {
|
||||
|
||||
|
||||
func setScenePhase(_ phase: ScenePhase) {
|
||||
let keepTalkActive = UserDefaults.standard.bool(forKey: "talk.background.enabled")
|
||||
switch phase {
|
||||
case .background:
|
||||
self.isBackgrounded = true
|
||||
self.stopGatewayHealthMonitor()
|
||||
self.backgroundedAt = Date()
|
||||
self.reconnectAfterBackgroundArmed = true
|
||||
// Release voice wake mic in background.
|
||||
// Be conservative: release the mic when the app backgrounds.
|
||||
self.backgroundVoiceWakeSuspended = self.voiceWake.suspendForExternalAudioCapture()
|
||||
let shouldKeepTalkActive = keepTalkActive && self.talkMode.isEnabled
|
||||
self.backgroundTalkKeptActive = shouldKeepTalkActive
|
||||
self.backgroundTalkSuspended = self.talkMode.suspendForBackground(keepActive: shouldKeepTalkActive)
|
||||
self.backgroundTalkSuspended = self.talkMode.suspendForBackground()
|
||||
case .active, .inactive:
|
||||
self.isBackgrounded = false
|
||||
if self.operatorConnected {
|
||||
@@ -293,12 +284,8 @@ final class NodeAppModel {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
let suspended = await MainActor.run { self.backgroundTalkSuspended }
|
||||
let keptActive = await MainActor.run { self.backgroundTalkKeptActive }
|
||||
await MainActor.run {
|
||||
self.backgroundTalkSuspended = false
|
||||
self.backgroundTalkKeptActive = false
|
||||
}
|
||||
await self.talkMode.resumeAfterBackground(wasSuspended: suspended, wasKeptActive: keptActive)
|
||||
await MainActor.run { self.backgroundTalkSuspended = false }
|
||||
await self.talkMode.resumeAfterBackground(wasSuspended: suspended)
|
||||
}
|
||||
}
|
||||
if phase == .active, self.reconnectAfterBackgroundArmed {
|
||||
@@ -353,7 +340,6 @@ final class NodeAppModel {
|
||||
}
|
||||
|
||||
func setTalkEnabled(_ enabled: Bool) {
|
||||
UserDefaults.standard.set(enabled, forKey: "talk.enabled")
|
||||
if enabled {
|
||||
// Voice wake holds the microphone continuously; talk mode needs exclusive access for STT.
|
||||
// When talk is enabled from the UI, prioritize talk and pause voice wake.
|
||||
@@ -365,11 +351,6 @@ final class NodeAppModel {
|
||||
self.talkVoiceWakeSuspended = false
|
||||
}
|
||||
self.talkMode.setEnabled(enabled)
|
||||
Task { [weak self] in
|
||||
await self?.pushTalkModeToGateway(
|
||||
enabled: enabled,
|
||||
phase: enabled ? "enabled" : "disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func requestLocationPermissions(mode: OpenClawLocationMode) async -> Bool {
|
||||
@@ -498,49 +479,16 @@ final class NodeAppModel {
|
||||
let stream = await self.operatorGateway.subscribeServerEvents(bufferingNewest: 200)
|
||||
for await evt in stream {
|
||||
if Task.isCancelled { return }
|
||||
guard evt.event == "voicewake.changed" else { continue }
|
||||
guard let payload = evt.payload else { continue }
|
||||
switch evt.event {
|
||||
case "voicewake.changed":
|
||||
struct Payload: Decodable { var triggers: [String] }
|
||||
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
|
||||
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
|
||||
VoiceWakePreferences.saveTriggerWords(triggers)
|
||||
case "talk.mode":
|
||||
struct Payload: Decodable {
|
||||
var enabled: Bool
|
||||
var phase: String?
|
||||
}
|
||||
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
|
||||
self.applyTalkModeSync(enabled: decoded.enabled, phase: decoded.phase)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
struct Payload: Decodable { var triggers: [String] }
|
||||
guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue }
|
||||
let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers)
|
||||
VoiceWakePreferences.saveTriggerWords(triggers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyTalkModeSync(enabled: Bool, phase: String?) {
|
||||
_ = phase
|
||||
guard self.talkMode.isEnabled != enabled else { return }
|
||||
self.setTalkEnabled(enabled)
|
||||
}
|
||||
|
||||
private func pushTalkModeToGateway(enabled: Bool, phase: String?) async {
|
||||
guard await self.isOperatorConnected() else { return }
|
||||
struct TalkModePayload: Encodable {
|
||||
var enabled: Bool
|
||||
var phase: String?
|
||||
}
|
||||
let payload = TalkModePayload(enabled: enabled, phase: phase)
|
||||
guard let data = try? JSONEncoder().encode(payload),
|
||||
let json = String(data: data, encoding: .utf8)
|
||||
else { return }
|
||||
_ = try? await self.operatorGateway.request(
|
||||
method: "talk.mode",
|
||||
paramsJSON: json,
|
||||
timeoutSeconds: 8)
|
||||
}
|
||||
|
||||
private func startGatewayHealthMonitor() {
|
||||
self.gatewayHealthMonitorDisabled = false
|
||||
self.gatewayHealthMonitor.start(
|
||||
@@ -629,8 +577,6 @@ final class NodeAppModel {
|
||||
switch route {
|
||||
case let .agent(link):
|
||||
await self.handleAgentDeepLink(link, originalURL: url)
|
||||
case .gateway:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1560,8 +1506,6 @@ extension NodeAppModel {
|
||||
|
||||
func disconnectGateway() {
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.nodeGatewayTask?.cancel()
|
||||
self.nodeGatewayTask = nil
|
||||
self.operatorGatewayTask?.cancel()
|
||||
@@ -1591,8 +1535,6 @@ extension NodeAppModel {
|
||||
private extension NodeAppModel {
|
||||
func prepareForGatewayConnect(url: URL, stableID: String) {
|
||||
self.gatewayAutoReconnectEnabled = true
|
||||
self.gatewayPairingPaused = false
|
||||
self.gatewayPairingRequestId = nil
|
||||
self.nodeGatewayTask?.cancel()
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.gatewayHealthMonitor.stop()
|
||||
@@ -1622,10 +1564,6 @@ private extension NodeAppModel {
|
||||
guard let self else { return }
|
||||
var attempt = 0
|
||||
while !Task.isCancelled {
|
||||
if self.gatewayPairingPaused {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
}
|
||||
if await self.isOperatorConnected() {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
@@ -1701,13 +1639,8 @@ private extension NodeAppModel {
|
||||
var attempt = 0
|
||||
var currentOptions = nodeOptions
|
||||
var didFallbackClientId = false
|
||||
var pausedForPairingApproval = false
|
||||
|
||||
while !Task.isCancelled {
|
||||
if self.gatewayPairingPaused {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
}
|
||||
if await self.isGatewayConnected() {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
continue
|
||||
@@ -1736,13 +1669,12 @@ private extension NodeAppModel {
|
||||
self.screen.errorText = nil
|
||||
UserDefaults.standard.set(true, forKey: "gateway.autoconnect")
|
||||
}
|
||||
GatewayDiagnostics.log("gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
|
||||
GatewayDiagnostics.log(
|
||||
"gateway connected host=\(url.host ?? "?") scheme=\(url.scheme ?? "?")")
|
||||
if let addr = await self.nodeGateway.currentRemoteAddress() {
|
||||
await MainActor.run { self.gatewayRemoteAddress = addr }
|
||||
}
|
||||
await self.showA2UIOnConnectIfNeeded()
|
||||
await self.onNodeGatewayConnected()
|
||||
await MainActor.run { SignificantLocationMonitor.startIfNeeded(locationService: self.locationService, locationMode: self.locationMode(), gateway: self.nodeGateway) }
|
||||
},
|
||||
onDisconnected: { [weak self] reason in
|
||||
guard let self else { return }
|
||||
@@ -1794,52 +1726,11 @@ private extension NodeAppModel {
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
GatewayDiagnostics.log("gateway connect error: \(error.localizedDescription)")
|
||||
|
||||
// If pairing is required, stop reconnect churn. The user must approve the request
|
||||
// on the gateway before another connect attempt will succeed, and retry loops can
|
||||
// generate multiple pending requests.
|
||||
let lower = error.localizedDescription.lowercased()
|
||||
if lower.contains("not_paired") || lower.contains("pairing required") {
|
||||
let requestId: String? = {
|
||||
// GatewayResponseError for connect decorates the message with `(requestId: ...)`.
|
||||
// Keep this resilient since other layers may wrap the text.
|
||||
let text = error.localizedDescription
|
||||
guard let start = text.range(of: "(requestId: ")?.upperBound else { return nil }
|
||||
guard let end = text[start...].firstIndex(of: ")") else { return nil }
|
||||
let raw = String(text[start..<end]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return raw.isEmpty ? nil : raw
|
||||
}()
|
||||
await MainActor.run {
|
||||
self.gatewayAutoReconnectEnabled = false
|
||||
self.gatewayPairingPaused = true
|
||||
self.gatewayPairingRequestId = requestId
|
||||
if let requestId, !requestId.isEmpty {
|
||||
self.gatewayStatusText =
|
||||
"Pairing required (requestId: \(requestId)). Approve on gateway, then tap Resume."
|
||||
} else {
|
||||
self.gatewayStatusText = "Pairing required. Approve on gateway, then tap Resume."
|
||||
}
|
||||
}
|
||||
// Hard stop the underlying WebSocket watchdog reconnects so the UI stays stable and
|
||||
// we don't generate multiple pending requests while waiting for approval.
|
||||
pausedForPairingApproval = true
|
||||
self.operatorGatewayTask?.cancel()
|
||||
self.operatorGatewayTask = nil
|
||||
await self.operatorGateway.disconnect()
|
||||
await self.nodeGateway.disconnect()
|
||||
break
|
||||
}
|
||||
|
||||
let sleepSeconds = min(8.0, 0.5 * pow(1.7, Double(attempt)))
|
||||
try? await Task.sleep(nanoseconds: UInt64(sleepSeconds * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
|
||||
if pausedForPairingApproval {
|
||||
// Leave the status text + request id intact so onboarding can guide the user.
|
||||
return
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.gatewayStatusText = "Offline"
|
||||
self.gatewayServerName = nil
|
||||
@@ -1866,7 +1757,7 @@ private extension NodeAppModel {
|
||||
clientId: clientId,
|
||||
clientMode: "ui",
|
||||
clientDisplayName: displayName,
|
||||
includeDeviceIdentity: true)
|
||||
includeDeviceIdentity: false)
|
||||
}
|
||||
|
||||
func legacyClientIdFallback(currentClientId: String, error: Error) -> String? {
|
||||
@@ -1884,17 +1775,6 @@ private extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
extension NodeAppModel {
|
||||
func reloadTalkConfig() {
|
||||
Task { [weak self] in
|
||||
await self?.talkMode.reloadConfig()
|
||||
}
|
||||
}
|
||||
|
||||
/// Back-compat hook retained for older gateway-connect flows.
|
||||
func onNodeGatewayConnected() async {}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension NodeAppModel {
|
||||
func _test_handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
|
||||
@@ -1928,9 +1808,5 @@ extension NodeAppModel {
|
||||
func _test_showLocalCanvasOnDisconnect() {
|
||||
self.showLocalCanvasOnDisconnect()
|
||||
}
|
||||
|
||||
func _test_applyTalkModeSync(enabled: Bool, phase: String? = nil) {
|
||||
self.applyTalkModeSync(enabled: enabled, phase: phase)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum OnboardingConnectionMode: String, CaseIterable {
|
||||
case homeNetwork = "home_network"
|
||||
case remoteDomain = "remote_domain"
|
||||
case developerLocal = "developer_local"
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .homeNetwork:
|
||||
"Home Network"
|
||||
case .remoteDomain:
|
||||
"Remote Domain"
|
||||
case .developerLocal:
|
||||
"Same Machine (Dev)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum OnboardingStateStore {
|
||||
private static let completedDefaultsKey = "onboarding.completed"
|
||||
private static let lastModeDefaultsKey = "onboarding.last_mode"
|
||||
private static let lastSuccessTimeDefaultsKey = "onboarding.last_success_time"
|
||||
|
||||
@MainActor
|
||||
static func shouldPresentOnLaunch(appModel: NodeAppModel, defaults: UserDefaults = .standard) -> Bool {
|
||||
if defaults.bool(forKey: Self.completedDefaultsKey) { return false }
|
||||
// If we have a last-known connection config, don't force onboarding on launch. Auto-connect
|
||||
// should handle reconnecting, and users can always open onboarding manually if needed.
|
||||
if GatewaySettingsStore.loadLastGatewayConnection() != nil { return false }
|
||||
return appModel.gatewayServerName == nil
|
||||
}
|
||||
|
||||
static func markCompleted(mode: OnboardingConnectionMode? = nil, defaults: UserDefaults = .standard) {
|
||||
defaults.set(true, forKey: Self.completedDefaultsKey)
|
||||
if let mode {
|
||||
defaults.set(mode.rawValue, forKey: Self.lastModeDefaultsKey)
|
||||
}
|
||||
defaults.set(Int(Date().timeIntervalSince1970), forKey: Self.lastSuccessTimeDefaultsKey)
|
||||
}
|
||||
|
||||
static func markIncomplete(defaults: UserDefaults = .standard) {
|
||||
defaults.set(false, forKey: Self.completedDefaultsKey)
|
||||
}
|
||||
|
||||
static func lastMode(defaults: UserDefaults = .standard) -> OnboardingConnectionMode? {
|
||||
let raw = defaults.string(forKey: Self.lastModeDefaultsKey)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !raw.isEmpty else { return nil }
|
||||
return OnboardingConnectionMode(rawValue: raw)
|
||||
}
|
||||
}
|
||||
@@ -1,852 +0,0 @@
|
||||
import CoreImage
|
||||
import OpenClawKit
|
||||
import PhotosUI
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
private enum OnboardingStep: Int, CaseIterable {
|
||||
case welcome
|
||||
case mode
|
||||
case connect
|
||||
case auth
|
||||
case success
|
||||
|
||||
var previous: Self? {
|
||||
Self(rawValue: self.rawValue - 1)
|
||||
}
|
||||
|
||||
var next: Self? {
|
||||
Self(rawValue: self.rawValue + 1)
|
||||
}
|
||||
|
||||
/// Progress label for the manual setup flow (mode → connect → auth → success).
|
||||
var manualProgressTitle: String {
|
||||
let manualSteps: [OnboardingStep] = [.mode, .connect, .auth, .success]
|
||||
guard let idx = manualSteps.firstIndex(of: self) else { return "" }
|
||||
return "Step \(idx + 1) of \(manualSteps.count)"
|
||||
}
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .welcome: "Welcome"
|
||||
case .mode: "Connection Mode"
|
||||
case .connect: "Connect"
|
||||
case .auth: "Authentication"
|
||||
case .success: "Connected"
|
||||
}
|
||||
}
|
||||
|
||||
var canGoBack: Bool {
|
||||
self != .welcome && self != .success
|
||||
}
|
||||
}
|
||||
|
||||
struct OnboardingWizardView: View {
|
||||
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
|
||||
@AppStorage("node.instanceId") private var instanceId: String = UUID().uuidString
|
||||
@AppStorage("gateway.discovery.domain") private var discoveryDomain: String = ""
|
||||
@AppStorage("onboarding.developerMode") private var developerModeEnabled: Bool = false
|
||||
@State private var step: OnboardingStep = .welcome
|
||||
@State private var selectedMode: OnboardingConnectionMode?
|
||||
@State private var manualHost: String = ""
|
||||
@State private var manualPort: Int = 18789
|
||||
@State private var manualPortText: String = "18789"
|
||||
@State private var manualTLS: Bool = true
|
||||
@State private var gatewayToken: String = ""
|
||||
@State private var gatewayPassword: String = ""
|
||||
@State private var connectMessage: String?
|
||||
@State private var statusLine: String = "Scan the QR code from your gateway to connect."
|
||||
@State private var connectingGatewayID: String?
|
||||
@State private var issue: GatewayConnectionIssue = .none
|
||||
@State private var didMarkCompleted = false
|
||||
@State private var didAutoPresentQR = false
|
||||
@State private var pairingRequestId: String?
|
||||
@State private var discoveryRestartTask: Task<Void, Never>?
|
||||
@State private var showQRScanner: Bool = false
|
||||
@State private var scannerError: String?
|
||||
@State private var selectedPhoto: PhotosPickerItem?
|
||||
|
||||
let allowSkip: Bool
|
||||
let onClose: () -> Void
|
||||
|
||||
private var isFullScreenStep: Bool {
|
||||
self.step == .welcome || self.step == .success
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
switch self.step {
|
||||
case .welcome:
|
||||
self.welcomeStep
|
||||
case .success:
|
||||
self.successStep
|
||||
default:
|
||||
Form {
|
||||
switch self.step {
|
||||
case .mode:
|
||||
self.modeStep
|
||||
case .connect:
|
||||
self.connectStep
|
||||
case .auth:
|
||||
self.authStep
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
}
|
||||
}
|
||||
.navigationTitle(self.isFullScreenStep ? "" : self.step.title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
if !self.isFullScreenStep {
|
||||
ToolbarItem(placement: .principal) {
|
||||
VStack(spacing: 2) {
|
||||
Text(self.step.title)
|
||||
.font(.headline)
|
||||
Text(self.step.manualProgressTitle)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
if self.step.canGoBack {
|
||||
Button {
|
||||
self.navigateBack()
|
||||
} label: {
|
||||
Label("Back", systemImage: "chevron.left")
|
||||
}
|
||||
} else if self.allowSkip {
|
||||
Button("Close") {
|
||||
self.onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
Spacer()
|
||||
Button("Done") {
|
||||
UIApplication.shared.sendAction(
|
||||
#selector(UIResponder.resignFirstResponder),
|
||||
to: nil, from: nil, for: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.gatewayTrustPromptAlert()
|
||||
.alert("QR Scanner Unavailable", isPresented: Binding(
|
||||
get: { self.scannerError != nil },
|
||||
set: { if !$0 { self.scannerError = nil } }
|
||||
)) {
|
||||
Button("OK", role: .cancel) {}
|
||||
} message: {
|
||||
Text(self.scannerError ?? "")
|
||||
}
|
||||
.sheet(isPresented: self.$showQRScanner) {
|
||||
NavigationStack {
|
||||
QRScannerView(
|
||||
onGatewayLink: { link in
|
||||
self.handleScannedLink(link)
|
||||
},
|
||||
onError: { error in
|
||||
self.showQRScanner = false
|
||||
self.statusLine = "Scanner error: \(error)"
|
||||
self.scannerError = error
|
||||
},
|
||||
onDismiss: {
|
||||
self.showQRScanner = false
|
||||
})
|
||||
.ignoresSafeArea()
|
||||
.navigationTitle("Scan QR Code")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button("Cancel") { self.showQRScanner = false }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
PhotosPicker(selection: self.$selectedPhoto, matching: .images) {
|
||||
Label("Photos", systemImage: "photo")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: self.selectedPhoto) { _, newValue in
|
||||
guard let item = newValue else { return }
|
||||
self.selectedPhoto = nil
|
||||
Task {
|
||||
guard let data = try? await item.loadTransferable(type: Data.self) else {
|
||||
self.showQRScanner = false
|
||||
self.scannerError = "Could not load the selected image."
|
||||
return
|
||||
}
|
||||
if let message = self.detectQRCode(from: data) {
|
||||
if let link = GatewayConnectDeepLink.fromSetupCode(message) {
|
||||
self.handleScannedLink(link)
|
||||
return
|
||||
}
|
||||
if let url = URL(string: message),
|
||||
let route = DeepLinkParser.parse(url),
|
||||
case let .gateway(link) = route
|
||||
{
|
||||
self.handleScannedLink(link)
|
||||
return
|
||||
}
|
||||
}
|
||||
self.showQRScanner = false
|
||||
self.scannerError = "No valid QR code found in the selected image."
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
self.initializeState()
|
||||
}
|
||||
.onDisappear {
|
||||
self.discoveryRestartTask?.cancel()
|
||||
self.discoveryRestartTask = nil
|
||||
}
|
||||
.onChange(of: self.discoveryDomain) { _, _ in
|
||||
self.scheduleDiscoveryRestart()
|
||||
}
|
||||
.onChange(of: self.manualPortText) { _, newValue in
|
||||
let digits = newValue.filter(\.isNumber)
|
||||
if digits != newValue {
|
||||
self.manualPortText = digits
|
||||
return
|
||||
}
|
||||
guard let parsed = Int(digits), parsed > 0 else {
|
||||
self.manualPort = 0
|
||||
return
|
||||
}
|
||||
self.manualPort = min(parsed, 65535)
|
||||
}
|
||||
.onChange(of: self.manualPort) { _, newValue in
|
||||
let normalized = newValue > 0 ? String(newValue) : ""
|
||||
if self.manualPortText != normalized {
|
||||
self.manualPortText = normalized
|
||||
}
|
||||
}
|
||||
.onChange(of: self.gatewayToken) { _, newValue in
|
||||
self.saveGatewayCredentials(token: newValue, password: self.gatewayPassword)
|
||||
}
|
||||
.onChange(of: self.gatewayPassword) { _, newValue in
|
||||
self.saveGatewayCredentials(token: self.gatewayToken, password: newValue)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, newValue in
|
||||
let next = GatewayConnectionIssue.detect(from: newValue)
|
||||
// Avoid "flip-flopping" the UI by clearing actionable issues when the underlying connection
|
||||
// transitions through intermediate statuses (e.g. Offline/Connecting while reconnect churns).
|
||||
if self.issue.needsPairing, next.needsPairing {
|
||||
// Keep the requestId sticky even if the status line omits it after we pause.
|
||||
let mergedRequestId = next.requestId ?? self.issue.requestId ?? self.pairingRequestId
|
||||
self.issue = .pairingRequired(requestId: mergedRequestId)
|
||||
} else if self.issue.needsPairing, !next.needsPairing {
|
||||
// Ignore non-pairing statuses until the user explicitly retries/scans again, or we connect.
|
||||
} else if self.issue.needsAuthToken, !next.needsAuthToken, !next.needsPairing {
|
||||
// Same idea for auth: once we learn credentials are missing/rejected, keep that sticky until
|
||||
// the user retries/scans again or we successfully connect.
|
||||
} else {
|
||||
self.issue = next
|
||||
}
|
||||
|
||||
if let requestId = next.requestId, !requestId.isEmpty {
|
||||
self.pairingRequestId = requestId
|
||||
}
|
||||
|
||||
// If the gateway tells us auth is missing/rejected, stop reconnect churn until the user intervenes.
|
||||
if next.needsAuthToken {
|
||||
self.appModel.gatewayAutoReconnectEnabled = false
|
||||
}
|
||||
|
||||
if self.issue.needsAuthToken || self.issue.needsPairing {
|
||||
self.step = .auth
|
||||
}
|
||||
if !newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
self.connectMessage = newValue
|
||||
self.statusLine = newValue
|
||||
}
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
guard newValue != nil else { return }
|
||||
self.statusLine = "Connected."
|
||||
if !self.didMarkCompleted, let selectedMode {
|
||||
OnboardingStateStore.markCompleted(mode: selectedMode)
|
||||
self.didMarkCompleted = true
|
||||
}
|
||||
self.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var welcomeStep: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "qrcode.viewfinder")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.tint)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Text("Welcome")
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.padding(.bottom, 8)
|
||||
|
||||
Text("Connect to your OpenClaw gateway")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 32)
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
self.statusLine = "Opening QR scanner…"
|
||||
self.showQRScanner = true
|
||||
} label: {
|
||||
Label("Scan QR Code", systemImage: "qrcode")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
|
||||
Button {
|
||||
self.step = .mode
|
||||
} label: {
|
||||
Text("Set Up Manually")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.large)
|
||||
}
|
||||
.padding(.bottom, 12)
|
||||
|
||||
Text(self.statusLine)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var modeStep: some View {
|
||||
Section("Connection Mode") {
|
||||
OnboardingModeRow(
|
||||
title: OnboardingConnectionMode.homeNetwork.title,
|
||||
subtitle: "LAN or Tailscale host",
|
||||
selected: self.selectedMode == .homeNetwork)
|
||||
{
|
||||
self.selectMode(.homeNetwork)
|
||||
}
|
||||
|
||||
OnboardingModeRow(
|
||||
title: OnboardingConnectionMode.remoteDomain.title,
|
||||
subtitle: "VPS with domain",
|
||||
selected: self.selectedMode == .remoteDomain)
|
||||
{
|
||||
self.selectMode(.remoteDomain)
|
||||
}
|
||||
|
||||
Toggle(
|
||||
"Developer mode",
|
||||
isOn: Binding(
|
||||
get: { self.developerModeEnabled },
|
||||
set: { newValue in
|
||||
self.developerModeEnabled = newValue
|
||||
if !newValue, self.selectedMode == .developerLocal {
|
||||
self.selectedMode = nil
|
||||
}
|
||||
}))
|
||||
|
||||
if self.developerModeEnabled {
|
||||
OnboardingModeRow(
|
||||
title: OnboardingConnectionMode.developerLocal.title,
|
||||
subtitle: "For local iOS app development",
|
||||
selected: self.selectedMode == .developerLocal)
|
||||
{
|
||||
self.selectMode(.developerLocal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button("Continue") {
|
||||
self.step = .connect
|
||||
}
|
||||
.disabled(self.selectedMode == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var connectStep: some View {
|
||||
if let selectedMode {
|
||||
Section {
|
||||
LabeledContent("Mode", value: selectedMode.title)
|
||||
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
|
||||
LabeledContent("Status", value: self.appModel.gatewayStatusText)
|
||||
LabeledContent("Progress", value: self.statusLine)
|
||||
} header: {
|
||||
Text("Status")
|
||||
} footer: {
|
||||
if let connectMessage {
|
||||
Text(connectMessage)
|
||||
}
|
||||
}
|
||||
|
||||
switch selectedMode {
|
||||
case .homeNetwork:
|
||||
self.homeNetworkConnectSection
|
||||
case .remoteDomain:
|
||||
self.remoteDomainConnectSection
|
||||
case .developerLocal:
|
||||
self.developerConnectSection
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
Text("Choose a mode first.")
|
||||
Button("Back to Mode Selection") {
|
||||
self.step = .mode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var homeNetworkConnectSection: some View {
|
||||
Group {
|
||||
Section("Discovered Gateways") {
|
||||
if self.gatewayController.gateways.isEmpty {
|
||||
Text("No gateways found yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ForEach(self.gatewayController.gateways) { gateway in
|
||||
let hasHost = self.gatewayHasResolvableHost(gateway)
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(gateway.name)
|
||||
if let host = gateway.lanHost ?? gateway.tailnetDns {
|
||||
Text(host)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
Task { await self.connectDiscoveredGateway(gateway) }
|
||||
} label: {
|
||||
if self.connectingGatewayID == gateway.id {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
} else if !hasHost {
|
||||
Text("Resolving…")
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil || !hasHost)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button("Restart Discovery") {
|
||||
self.gatewayController.restartDiscovery()
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
}
|
||||
|
||||
self.manualConnectionFieldsSection(title: "Manual Fallback")
|
||||
}
|
||||
}
|
||||
|
||||
private var remoteDomainConnectSection: some View {
|
||||
self.manualConnectionFieldsSection(title: "Domain Settings")
|
||||
}
|
||||
|
||||
private var developerConnectSection: some View {
|
||||
Section {
|
||||
TextField("Host", text: self.$manualHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
TextField("Port", text: self.$manualPortText)
|
||||
.keyboardType(.numberPad)
|
||||
Toggle("Use TLS", isOn: self.$manualTLS)
|
||||
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
|
||||
} header: {
|
||||
Text("Developer Local")
|
||||
} footer: {
|
||||
Text("Default host is localhost. Use your Mac LAN IP if simulator networking requires it.")
|
||||
}
|
||||
}
|
||||
|
||||
private var authStep: some View {
|
||||
Group {
|
||||
Section("Authentication") {
|
||||
TextField("Gateway Auth Token", text: self.$gatewayToken)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
|
||||
if self.issue.needsAuthToken {
|
||||
Text("Gateway rejected credentials. Scan a fresh QR code or update token/password.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Auth token looks valid.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if self.issue.needsPairing {
|
||||
Section {
|
||||
Button("Copy: openclaw devices list") {
|
||||
UIPasteboard.general.string = "openclaw devices list"
|
||||
}
|
||||
|
||||
if let id = self.issue.requestId {
|
||||
Button("Copy: openclaw devices approve \(id)") {
|
||||
UIPasteboard.general.string = "openclaw devices approve \(id)"
|
||||
}
|
||||
} else {
|
||||
Button("Copy: openclaw devices approve <requestId>") {
|
||||
UIPasteboard.general.string = "openclaw devices approve <requestId>"
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Pairing Approval")
|
||||
} footer: {
|
||||
Text("Approve this device on the gateway, then tap \"Resume After Approval\" below.")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
Task { await self.retryLastAttempt() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "retry" {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
} else {
|
||||
Text("Retry Connection")
|
||||
}
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
|
||||
Button {
|
||||
self.resumeAfterPairingApproval()
|
||||
} label: {
|
||||
Label("Resume After Approval", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil || !self.issue.needsPairing)
|
||||
|
||||
Button {
|
||||
self.openQRScannerFromOnboarding()
|
||||
} label: {
|
||||
Label("Scan QR Code Again", systemImage: "qrcode.viewfinder")
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var successStep: some View {
|
||||
VStack(spacing: 0) {
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.system(size: 64))
|
||||
.foregroundStyle(.green)
|
||||
.padding(.bottom, 20)
|
||||
|
||||
Text("Connected")
|
||||
.font(.largeTitle.weight(.bold))
|
||||
.padding(.bottom, 8)
|
||||
|
||||
let server = self.appModel.gatewayServerName ?? "gateway"
|
||||
Text(server)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
if let addr = self.appModel.gatewayRemoteAddress {
|
||||
Text(addr)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
self.onClose()
|
||||
} label: {
|
||||
Text("Open OpenClaw")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 48)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func manualConnectionFieldsSection(title: String) -> some View {
|
||||
Section(title) {
|
||||
TextField("Host", text: self.$manualHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
TextField("Port", text: self.$manualPortText)
|
||||
.keyboardType(.numberPad)
|
||||
Toggle("Use TLS", isOn: self.$manualTLS)
|
||||
TextField("Discovery Domain (optional)", text: self.$discoveryDomain)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
} else {
|
||||
Text("Connect")
|
||||
}
|
||||
}
|
||||
.disabled(!self.canConnectManual || self.connectingGatewayID != nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleScannedLink(_ link: GatewayConnectDeepLink) {
|
||||
self.manualHost = link.host
|
||||
self.manualPort = link.port
|
||||
self.manualTLS = link.tls
|
||||
if let token = link.token {
|
||||
self.gatewayToken = token
|
||||
}
|
||||
if let password = link.password {
|
||||
self.gatewayPassword = password
|
||||
}
|
||||
self.saveGatewayCredentials(token: self.gatewayToken, password: self.gatewayPassword)
|
||||
self.showQRScanner = false
|
||||
self.connectMessage = "Connecting via QR code…"
|
||||
self.statusLine = "QR loaded. Connecting to \(link.host):\(link.port)…"
|
||||
if self.selectedMode == nil {
|
||||
self.selectedMode = link.tls ? .remoteDomain : .homeNetwork
|
||||
}
|
||||
Task { await self.connectManual() }
|
||||
}
|
||||
|
||||
private func openQRScannerFromOnboarding() {
|
||||
// Stop active reconnect loops before scanning new credentials.
|
||||
self.appModel.disconnectGateway()
|
||||
self.connectingGatewayID = nil
|
||||
self.connectMessage = nil
|
||||
self.issue = .none
|
||||
self.pairingRequestId = nil
|
||||
self.statusLine = "Opening QR scanner…"
|
||||
self.showQRScanner = true
|
||||
}
|
||||
|
||||
private func resumeAfterPairingApproval() {
|
||||
// We intentionally stop reconnect churn while unpaired to avoid generating multiple pending requests.
|
||||
self.appModel.gatewayAutoReconnectEnabled = true
|
||||
self.appModel.gatewayPairingPaused = false
|
||||
self.connectMessage = "Retrying after approval…"
|
||||
self.statusLine = "Retrying after approval…"
|
||||
Task { await self.retryLastAttempt() }
|
||||
}
|
||||
|
||||
private func detectQRCode(from data: Data) -> String? {
|
||||
guard let ciImage = CIImage(data: data) else { return nil }
|
||||
let detector = CIDetector(
|
||||
ofType: CIDetectorTypeQRCode, context: nil,
|
||||
options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])
|
||||
let features = detector?.features(in: ciImage) ?? []
|
||||
for feature in features {
|
||||
if let qr = feature as? CIQRCodeFeature, let message = qr.messageString {
|
||||
return message
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func navigateBack() {
|
||||
guard let target = self.step.previous else { return }
|
||||
self.connectingGatewayID = nil
|
||||
self.connectMessage = nil
|
||||
self.step = target
|
||||
}
|
||||
private var canConnectManual: Bool {
|
||||
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return !host.isEmpty && self.manualPort > 0 && self.manualPort <= 65535
|
||||
}
|
||||
|
||||
private func initializeState() {
|
||||
if self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
if let last = GatewaySettingsStore.loadLastGatewayConnection() {
|
||||
switch last {
|
||||
case let .manual(host, port, useTLS, _):
|
||||
self.manualHost = host
|
||||
self.manualPort = port
|
||||
self.manualTLS = useTLS
|
||||
case .discovered:
|
||||
self.manualHost = "openclaw.local"
|
||||
self.manualPort = 18789
|
||||
self.manualTLS = true
|
||||
}
|
||||
} else {
|
||||
self.manualHost = "openclaw.local"
|
||||
self.manualPort = 18789
|
||||
self.manualTLS = true
|
||||
}
|
||||
}
|
||||
self.manualPortText = self.manualPort > 0 ? String(self.manualPort) : ""
|
||||
if self.selectedMode == nil {
|
||||
self.selectedMode = OnboardingStateStore.lastMode()
|
||||
}
|
||||
if self.selectedMode == .developerLocal && self.manualHost == "openclaw.local" {
|
||||
self.manualHost = "localhost"
|
||||
self.manualTLS = false
|
||||
}
|
||||
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
|
||||
}
|
||||
|
||||
let hasSavedGateway = GatewaySettingsStore.loadLastGatewayConnection() != nil
|
||||
let hasToken = !self.gatewayToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
let hasPassword = !self.gatewayPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
if !self.didAutoPresentQR, !hasSavedGateway, !hasToken, !hasPassword {
|
||||
self.didAutoPresentQR = true
|
||||
self.statusLine = "No saved pairing found. Scan QR code to connect."
|
||||
self.showQRScanner = true
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleDiscoveryRestart() {
|
||||
self.discoveryRestartTask?.cancel()
|
||||
self.discoveryRestartTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
self.gatewayController.restartDiscovery()
|
||||
}
|
||||
}
|
||||
|
||||
private func saveGatewayCredentials(token: String, password: String) {
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmedInstanceId.isEmpty else { return }
|
||||
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: trimmedInstanceId)
|
||||
let trimmedPassword = password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: trimmedInstanceId)
|
||||
}
|
||||
|
||||
private func connectDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) async {
|
||||
self.connectingGatewayID = gateway.id
|
||||
self.issue = .none
|
||||
self.connectMessage = "Connecting to \(gateway.name)…"
|
||||
self.statusLine = "Connecting to \(gateway.name)…"
|
||||
defer { self.connectingGatewayID = nil }
|
||||
await self.gatewayController.connect(gateway)
|
||||
}
|
||||
|
||||
private func selectMode(_ mode: OnboardingConnectionMode) {
|
||||
self.selectedMode = mode
|
||||
self.applyModeDefaults(mode)
|
||||
}
|
||||
|
||||
private func applyModeDefaults(_ mode: OnboardingConnectionMode) {
|
||||
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let hostIsDefaultLike = host.isEmpty || host == "openclaw.local" || host == "localhost"
|
||||
|
||||
switch mode {
|
||||
case .homeNetwork:
|
||||
if hostIsDefaultLike { self.manualHost = "openclaw.local" }
|
||||
self.manualTLS = true
|
||||
if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 }
|
||||
case .remoteDomain:
|
||||
if host == "openclaw.local" || host == "localhost" { self.manualHost = "" }
|
||||
self.manualTLS = true
|
||||
if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 }
|
||||
case .developerLocal:
|
||||
if hostIsDefaultLike { self.manualHost = "localhost" }
|
||||
self.manualTLS = false
|
||||
if self.manualPort <= 0 || self.manualPort > 65535 { self.manualPort = 18789 }
|
||||
}
|
||||
}
|
||||
|
||||
private func gatewayHasResolvableHost(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool {
|
||||
let lanHost = gateway.lanHost?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if !lanHost.isEmpty { return true }
|
||||
let tailnetDns = gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return !tailnetDns.isEmpty
|
||||
}
|
||||
|
||||
private func connectManual() async {
|
||||
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !host.isEmpty, self.manualPort > 0, self.manualPort <= 65535 else { return }
|
||||
self.connectingGatewayID = "manual"
|
||||
self.issue = .none
|
||||
self.connectMessage = "Connecting to \(host)…"
|
||||
self.statusLine = "Connecting to \(host):\(self.manualPort)…"
|
||||
defer { self.connectingGatewayID = nil }
|
||||
await self.gatewayController.connectManual(host: host, port: self.manualPort, useTLS: self.manualTLS)
|
||||
}
|
||||
|
||||
private func retryLastAttempt() async {
|
||||
self.connectingGatewayID = "retry"
|
||||
self.issue = .none
|
||||
self.connectMessage = "Retrying…"
|
||||
self.statusLine = "Retrying last connection…"
|
||||
defer { self.connectingGatewayID = nil }
|
||||
await self.gatewayController.connectLastKnown()
|
||||
}
|
||||
}
|
||||
|
||||
private struct OnboardingModeRow: View {
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let selected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: self.action) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(self.title)
|
||||
.font(.body.weight(.semibold))
|
||||
Text(self.subtitle)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: self.selected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(self.selected ? Color.accentColor : Color.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import OpenClawKit
|
||||
import SwiftUI
|
||||
import VisionKit
|
||||
|
||||
struct QRScannerView: UIViewControllerRepresentable {
|
||||
let onGatewayLink: (GatewayConnectDeepLink) -> Void
|
||||
let onError: (String) -> Void
|
||||
let onDismiss: () -> Void
|
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
guard DataScannerViewController.isSupported else {
|
||||
context.coordinator.reportError("QR scanning is not supported on this device.")
|
||||
return UIViewController()
|
||||
}
|
||||
guard DataScannerViewController.isAvailable else {
|
||||
context.coordinator.reportError("Camera scanning is currently unavailable.")
|
||||
return UIViewController()
|
||||
}
|
||||
let scanner = DataScannerViewController(
|
||||
recognizedDataTypes: [.barcode(symbologies: [.qr])],
|
||||
isHighlightingEnabled: true)
|
||||
scanner.delegate = context.coordinator
|
||||
do {
|
||||
try scanner.startScanning()
|
||||
} catch {
|
||||
context.coordinator.reportError("Could not start QR scanner.")
|
||||
}
|
||||
return scanner
|
||||
}
|
||||
|
||||
func updateUIViewController(_: UIViewController, context _: Context) {}
|
||||
|
||||
static func dismantleUIViewController(_ uiViewController: UIViewController, coordinator: Coordinator) {
|
||||
if let scanner = uiViewController as? DataScannerViewController {
|
||||
scanner.stopScanning()
|
||||
}
|
||||
coordinator.parent.onDismiss()
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(parent: self)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
|
||||
let parent: QRScannerView
|
||||
private var handled = false
|
||||
private var reportedError = false
|
||||
|
||||
init(parent: QRScannerView) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func reportError(_ message: String) {
|
||||
guard !self.reportedError else { return }
|
||||
self.reportedError = true
|
||||
Task { @MainActor in
|
||||
self.parent.onError(message)
|
||||
}
|
||||
}
|
||||
|
||||
func dataScanner(_: DataScannerViewController, didAdd items: [RecognizedItem], allItems _: [RecognizedItem]) {
|
||||
guard !self.handled else { return }
|
||||
for item in items {
|
||||
guard case let .barcode(barcode) = item,
|
||||
let payload = barcode.payloadStringValue
|
||||
else { continue }
|
||||
|
||||
// Try setup code format first (base64url JSON from /pair qr).
|
||||
if let link = GatewayConnectDeepLink.fromSetupCode(payload) {
|
||||
self.handled = true
|
||||
self.parent.onGatewayLink(link)
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to deep link URL format (openclaw://gateway?...).
|
||||
if let url = URL(string: payload),
|
||||
let route = DeepLinkParser.parse(url),
|
||||
case let .gateway(link) = route
|
||||
{
|
||||
self.handled = true
|
||||
self.parent.onGatewayLink(link)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dataScanner(_: DataScannerViewController, didRemove _: [RecognizedItem], allItems _: [RecognizedItem]) {}
|
||||
|
||||
func dataScanner(
|
||||
_: DataScannerViewController,
|
||||
becameUnavailableWithError _: DataScannerViewController.ScanningUnavailable)
|
||||
{
|
||||
self.reportError("Camera is not available on this device.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct OpenClawApp: App {
|
||||
@@ -8,7 +7,6 @@ struct OpenClawApp: App {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
init() {
|
||||
Self.installUncaughtExceptionLogger()
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
let appModel = NodeAppModel()
|
||||
_appModel = State(initialValue: appModel)
|
||||
@@ -31,18 +29,3 @@ struct OpenClawApp: App {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension OpenClawApp {
|
||||
private static func installUncaughtExceptionLogger() {
|
||||
NSLog("OpenClaw: installing uncaught exception handler")
|
||||
NSSetUncaughtExceptionHandler { exception in
|
||||
// Useful when the app hits NSExceptions from SwiftUI/WebKit internals; these do not
|
||||
// produce a normal Swift error backtrace.
|
||||
let reason = exception.reason ?? "(no reason)"
|
||||
NSLog("UNCAUGHT EXCEPTION: %@ %@", exception.name.rawValue, reason)
|
||||
for line in exception.callStackSymbols {
|
||||
NSLog(" %@", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,69 +3,34 @@ import UIKit
|
||||
|
||||
struct RootCanvas: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(GatewayConnectionController.self) private var gatewayController
|
||||
@Environment(VoiceWakeManager.self) private var voiceWake
|
||||
@Environment(\.colorScheme) private var systemColorScheme
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
||||
@AppStorage("screen.preventSleep") private var preventSleep: Bool = true
|
||||
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
||||
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
|
||||
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
|
||||
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
|
||||
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
|
||||
@AppStorage("gateway.manual.enabled") private var manualGatewayEnabled: Bool = false
|
||||
@AppStorage("gateway.manual.host") private var manualGatewayHost: String = ""
|
||||
@AppStorage("onboarding.quickSetupDismissed") private var quickSetupDismissed: Bool = false
|
||||
@State private var presentedSheet: PresentedSheet?
|
||||
@State private var voiceWakeToastText: String?
|
||||
@State private var toastDismissTask: Task<Void, Never>?
|
||||
@State private var showOnboarding: Bool = false
|
||||
@State private var onboardingAllowSkip: Bool = true
|
||||
@State private var didEvaluateOnboarding: Bool = false
|
||||
@State private var didAutoOpenSettings: Bool = false
|
||||
|
||||
private enum PresentedSheet: Identifiable {
|
||||
case settings
|
||||
case chat
|
||||
case quickSetup
|
||||
|
||||
var id: Int {
|
||||
switch self {
|
||||
case .settings: 0
|
||||
case .chat: 1
|
||||
case .quickSetup: 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum StartupPresentationRoute: Equatable {
|
||||
case none
|
||||
case onboarding
|
||||
case settings
|
||||
}
|
||||
|
||||
static func startupPresentationRoute(
|
||||
gatewayConnected: Bool,
|
||||
hasConnectedOnce: Bool,
|
||||
onboardingComplete: Bool,
|
||||
hasExistingGatewayConfig: Bool,
|
||||
shouldPresentOnLaunch: Bool) -> StartupPresentationRoute
|
||||
{
|
||||
if gatewayConnected {
|
||||
return .none
|
||||
}
|
||||
// On first run or explicit launch onboarding state, onboarding always wins.
|
||||
if shouldPresentOnLaunch || !hasConnectedOnce || !onboardingComplete {
|
||||
return .onboarding
|
||||
}
|
||||
// Settings auto-open is a recovery path for previously-connected installs only.
|
||||
if !hasExistingGatewayConfig {
|
||||
return .settings
|
||||
}
|
||||
return .none
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
CanvasContent(
|
||||
@@ -92,58 +57,27 @@ struct RootCanvas: View {
|
||||
switch sheet {
|
||||
case .settings:
|
||||
SettingsTab()
|
||||
.environment(self.appModel)
|
||||
.environment(self.appModel.voiceWake)
|
||||
.environment(self.gatewayController)
|
||||
case .chat:
|
||||
ChatSheet(
|
||||
// Mobile chat UI should use the node role RPC surface (chat.* / sessions.*)
|
||||
// to avoid requiring operator scopes like operator.read.
|
||||
gateway: self.appModel.gatewaySession,
|
||||
gateway: self.appModel.operatorSession,
|
||||
sessionKey: self.appModel.mainSessionKey,
|
||||
agentName: self.appModel.activeAgentName,
|
||||
userAccent: self.appModel.seamColor)
|
||||
case .quickSetup:
|
||||
GatewayQuickSetupSheet()
|
||||
.environment(self.appModel)
|
||||
.environment(self.gatewayController)
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: self.$showOnboarding) {
|
||||
OnboardingWizardView(
|
||||
allowSkip: self.onboardingAllowSkip,
|
||||
onClose: {
|
||||
self.showOnboarding = false
|
||||
})
|
||||
.environment(self.appModel)
|
||||
.environment(self.appModel.voiceWake)
|
||||
.environment(self.gatewayController)
|
||||
}
|
||||
.onAppear { self.updateIdleTimer() }
|
||||
.onAppear { self.evaluateOnboardingPresentation(force: false) }
|
||||
.onAppear { self.maybeAutoOpenSettings() }
|
||||
.onChange(of: self.preventSleep) { _, _ in self.updateIdleTimer() }
|
||||
.onChange(of: self.scenePhase) { _, _ in self.updateIdleTimer() }
|
||||
.onAppear { self.maybeShowQuickSetup() }
|
||||
.onChange(of: self.gatewayController.gateways.count) { _, _ in self.maybeShowQuickSetup() }
|
||||
.onAppear { self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.canvasDebugStatusEnabled) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayStatusText) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
if newValue != nil {
|
||||
self.showOnboarding = false
|
||||
}
|
||||
}
|
||||
.onChange(of: self.onboardingRequestID) { _, _ in
|
||||
self.evaluateOnboardingPresentation(force: true)
|
||||
}
|
||||
.onChange(of: self.appModel.gatewayRemoteAddress) { _, _ in self.updateCanvasDebugStatus() }
|
||||
.onChange(of: self.appModel.gatewayServerName) { _, newValue in
|
||||
if newValue != nil {
|
||||
self.onboardingComplete = true
|
||||
self.hasConnectedOnce = true
|
||||
OnboardingStateStore.markCompleted(mode: nil)
|
||||
}
|
||||
self.maybeAutoOpenSettings()
|
||||
}
|
||||
@@ -202,31 +136,11 @@ struct RootCanvas: View {
|
||||
self.appModel.screen.updateDebugStatus(title: title, subtitle: subtitle)
|
||||
}
|
||||
|
||||
private func evaluateOnboardingPresentation(force: Bool) {
|
||||
if force {
|
||||
self.onboardingAllowSkip = true
|
||||
self.showOnboarding = true
|
||||
return
|
||||
}
|
||||
|
||||
guard !self.didEvaluateOnboarding else { return }
|
||||
self.didEvaluateOnboarding = true
|
||||
let route = Self.startupPresentationRoute(
|
||||
gatewayConnected: self.appModel.gatewayServerName != nil,
|
||||
hasConnectedOnce: self.hasConnectedOnce,
|
||||
onboardingComplete: self.onboardingComplete,
|
||||
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
|
||||
shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel))
|
||||
switch route {
|
||||
case .none:
|
||||
break
|
||||
case .onboarding:
|
||||
self.onboardingAllowSkip = true
|
||||
self.showOnboarding = true
|
||||
case .settings:
|
||||
self.didAutoOpenSettings = true
|
||||
self.presentedSheet = .settings
|
||||
}
|
||||
private func shouldAutoOpenSettings() -> Bool {
|
||||
if self.appModel.gatewayServerName != nil { return false }
|
||||
if !self.hasConnectedOnce { return true }
|
||||
if !self.onboardingComplete { return true }
|
||||
return !self.hasExistingGatewayConfig()
|
||||
}
|
||||
|
||||
private func hasExistingGatewayConfig() -> Bool {
|
||||
@@ -237,26 +151,10 @@ struct RootCanvas: View {
|
||||
|
||||
private func maybeAutoOpenSettings() {
|
||||
guard !self.didAutoOpenSettings else { return }
|
||||
guard !self.showOnboarding else { return }
|
||||
let route = Self.startupPresentationRoute(
|
||||
gatewayConnected: self.appModel.gatewayServerName != nil,
|
||||
hasConnectedOnce: self.hasConnectedOnce,
|
||||
onboardingComplete: self.onboardingComplete,
|
||||
hasExistingGatewayConfig: self.hasExistingGatewayConfig(),
|
||||
shouldPresentOnLaunch: false)
|
||||
guard route == .settings else { return }
|
||||
guard self.shouldAutoOpenSettings() else { return }
|
||||
self.didAutoOpenSettings = true
|
||||
self.presentedSheet = .settings
|
||||
}
|
||||
|
||||
private func maybeShowQuickSetup() {
|
||||
guard !self.quickSetupDismissed else { return }
|
||||
guard !self.showOnboarding else { return }
|
||||
guard self.presentedSheet == nil else { return }
|
||||
guard self.appModel.gatewayServerName == nil else { return }
|
||||
guard !self.gatewayController.gateways.isEmpty else { return }
|
||||
self.presentedSheet = .quickSetup
|
||||
}
|
||||
}
|
||||
|
||||
private struct CanvasContent: View {
|
||||
@@ -358,64 +256,11 @@ private struct CanvasContent: View {
|
||||
}
|
||||
|
||||
private var statusActivity: StatusPill.Activity? {
|
||||
// Status pill owns transient activity state so it doesn't overlap the connection indicator.
|
||||
if self.appModel.isBackgrounded {
|
||||
return StatusPill.Activity(
|
||||
title: "Foreground required",
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
tint: .orange)
|
||||
}
|
||||
|
||||
let gatewayStatus = self.appModel.gatewayStatusText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let gatewayLower = gatewayStatus.lowercased()
|
||||
if gatewayLower.contains("repair") {
|
||||
return StatusPill.Activity(title: "Repairing…", systemImage: "wrench.and.screwdriver", tint: .orange)
|
||||
}
|
||||
if gatewayLower.contains("approval") || gatewayLower.contains("pairing") {
|
||||
return StatusPill.Activity(title: "Approval pending", systemImage: "person.crop.circle.badge.clock")
|
||||
}
|
||||
// Avoid duplicating the primary gateway status ("Connecting…") in the activity slot.
|
||||
|
||||
if self.appModel.screenRecordActive {
|
||||
return StatusPill.Activity(title: "Recording screen…", systemImage: "record.circle.fill", tint: .red)
|
||||
}
|
||||
|
||||
if let cameraHUDText, !cameraHUDText.isEmpty, let cameraHUDKind {
|
||||
let systemImage: String
|
||||
let tint: Color?
|
||||
switch cameraHUDKind {
|
||||
case .photo:
|
||||
systemImage = "camera.fill"
|
||||
tint = nil
|
||||
case .recording:
|
||||
systemImage = "video.fill"
|
||||
tint = .red
|
||||
case .success:
|
||||
systemImage = "checkmark.circle.fill"
|
||||
tint = .green
|
||||
case .error:
|
||||
systemImage = "exclamationmark.triangle.fill"
|
||||
tint = .red
|
||||
}
|
||||
return StatusPill.Activity(title: cameraHUDText, systemImage: systemImage, tint: tint)
|
||||
}
|
||||
|
||||
if self.voiceWakeEnabled {
|
||||
let voiceStatus = self.appModel.voiceWake.statusText
|
||||
if voiceStatus.localizedCaseInsensitiveContains("microphone permission") {
|
||||
return StatusPill.Activity(title: "Mic permission", systemImage: "mic.slash", tint: .orange)
|
||||
}
|
||||
if voiceStatus == "Paused" {
|
||||
// Talk mode intentionally pauses voice wake to release the mic. Don't spam the HUD for that case.
|
||||
if self.appModel.talkMode.isEnabled {
|
||||
return nil
|
||||
}
|
||||
let suffix = self.appModel.isBackgrounded ? " (background)" : ""
|
||||
return StatusPill.Activity(title: "Voice Wake paused\(suffix)", systemImage: "pause.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
StatusActivityBuilder.build(
|
||||
appModel: self.appModel,
|
||||
voiceWakeEnabled: self.voiceWakeEnabled,
|
||||
cameraHUDText: self.cameraHUDText,
|
||||
cameraHUDKind: self.cameraHUDKind)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import SwiftUI
|
||||
struct RootTabs: View {
|
||||
@Environment(NodeAppModel.self) private var appModel
|
||||
@Environment(VoiceWakeManager.self) private var voiceWake
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@AppStorage(VoiceWakePreferences.enabledKey) private var voiceWakeEnabled: Bool = false
|
||||
@State private var selectedTab: Int = 0
|
||||
@State private var voiceWakeToastText: String?
|
||||
@@ -53,14 +52,14 @@ struct RootTabs: View {
|
||||
guard !trimmed.isEmpty else { return }
|
||||
|
||||
self.toastDismissTask?.cancel()
|
||||
withAnimation(self.reduceMotion ? .none : .spring(response: 0.25, dampingFraction: 0.85)) {
|
||||
withAnimation(.spring(response: 0.25, dampingFraction: 0.85)) {
|
||||
self.voiceWakeToastText = trimmed
|
||||
}
|
||||
|
||||
self.toastDismissTask = Task {
|
||||
try? await Task.sleep(nanoseconds: 2_300_000_000)
|
||||
await MainActor.run {
|
||||
withAnimation(self.reduceMotion ? .none : .easeOut(duration: 0.25)) {
|
||||
withAnimation(.easeOut(duration: 0.25)) {
|
||||
self.voiceWakeToastText = nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +28,6 @@ protocol LocationServicing: Sendable {
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
maxAgeMs: Int?,
|
||||
timeoutMs: Int?) async throws -> CLLocation
|
||||
func startLocationUpdates(
|
||||
desiredAccuracy: OpenClawLocationAccuracy,
|
||||
significantChangesOnly: Bool) -> AsyncStream<CLLocation>
|
||||
func stopLocationUpdates()
|
||||
func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void)
|
||||
func stopMonitoringSignificantLocationChanges()
|
||||
}
|
||||
|
||||
protocol DeviceStatusServicing: Sendable {
|
||||
|
||||
@@ -15,8 +15,6 @@ struct SettingsTab: View {
|
||||
@AppStorage("voiceWake.enabled") private var voiceWakeEnabled: Bool = false
|
||||
@AppStorage("talk.enabled") private var talkEnabled: Bool = false
|
||||
@AppStorage("talk.button.enabled") private var talkButtonEnabled: Bool = true
|
||||
@AppStorage("talk.background.enabled") private var talkBackgroundEnabled: Bool = false
|
||||
@AppStorage("talk.voiceDirectiveHint.enabled") private var talkVoiceDirectiveHintEnabled: Bool = true
|
||||
@AppStorage("camera.enabled") private var cameraEnabled: Bool = true
|
||||
@AppStorage("location.enabledMode") private var locationEnabledModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@AppStorage("location.preciseEnabled") private var locationPreciseEnabled: Bool = true
|
||||
@@ -30,27 +28,17 @@ struct SettingsTab: View {
|
||||
@AppStorage("gateway.manual.tls") private var manualGatewayTLS: Bool = true
|
||||
@AppStorage("gateway.discovery.debugLogs") private var discoveryDebugLogsEnabled: Bool = false
|
||||
@AppStorage("canvas.debugStatusEnabled") private var canvasDebugStatusEnabled: Bool = false
|
||||
|
||||
// Onboarding control (RootCanvas listens to onboarding.requestID and force-opens the wizard).
|
||||
@AppStorage("onboarding.requestID") private var onboardingRequestID: Int = 0
|
||||
@AppStorage("gateway.onboardingComplete") private var onboardingComplete: Bool = false
|
||||
@AppStorage("gateway.hasConnectedOnce") private var hasConnectedOnce: Bool = false
|
||||
|
||||
@State private var connectingGatewayID: String?
|
||||
@State private var localIPAddress: String?
|
||||
@State private var lastLocationModeRaw: String = OpenClawLocationMode.off.rawValue
|
||||
@State private var gatewayToken: String = ""
|
||||
@State private var gatewayPassword: String = ""
|
||||
@State private var talkElevenLabsApiKey: String = ""
|
||||
@AppStorage("gateway.setupCode") private var setupCode: String = ""
|
||||
@State private var setupStatusText: String?
|
||||
@State private var manualGatewayPortText: String = ""
|
||||
@State private var gatewayExpanded: Bool = true
|
||||
@State private var selectedAgentPickerId: String = ""
|
||||
|
||||
@State private var showResetOnboardingAlert: Bool = false
|
||||
@State private var suppressCredentialPersist: Bool = false
|
||||
|
||||
private let gatewayLogger = Logger(subsystem: "ai.openclaw.ios", category: "GatewaySettings")
|
||||
|
||||
var body: some View {
|
||||
@@ -115,6 +103,7 @@ struct SettingsTab: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
DisclosureGroup("Advanced") {
|
||||
if self.appModel.gatewayServerName == nil {
|
||||
LabeledContent("Discovery", value: self.gatewayController.discoveryStatusText)
|
||||
}
|
||||
@@ -159,74 +148,69 @@ struct SettingsTab: View {
|
||||
self.gatewayList(showing: .all)
|
||||
}
|
||||
|
||||
DisclosureGroup("Advanced") {
|
||||
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
|
||||
Toggle("Use Manual Gateway", isOn: self.$manualGatewayEnabled)
|
||||
|
||||
TextField("Host", text: self.$manualGatewayHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
TextField("Host", text: self.$manualGatewayHost)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
TextField("Port (optional)", text: self.manualPortBinding)
|
||||
.keyboardType(.numberPad)
|
||||
TextField("Port (optional)", text: self.manualPortBinding)
|
||||
.keyboardType(.numberPad)
|
||||
|
||||
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
|
||||
Toggle("Use TLS", isOn: self.$manualGatewayTLS)
|
||||
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
} else {
|
||||
Text("Connect (Manual)")
|
||||
Button {
|
||||
Task { await self.connectManual() }
|
||||
} label: {
|
||||
if self.connectingGatewayID == "manual" {
|
||||
HStack(spacing: 8) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text("Connecting…")
|
||||
}
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty || !self.manualPortIsValid)
|
||||
|
||||
Text(
|
||||
"Use this when mDNS/Bonjour discovery is blocked. "
|
||||
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
|
||||
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
|
||||
self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
|
||||
}
|
||||
|
||||
NavigationLink("Discovery Logs") {
|
||||
GatewayDiscoveryDebugLogView()
|
||||
}
|
||||
|
||||
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
|
||||
|
||||
TextField("Gateway Auth Token", text: self.$gatewayToken)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
|
||||
Button("Reset Onboarding", role: .destructive) {
|
||||
self.showResetOnboardingAlert = true
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Debug")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(self.gatewayDebugText())
|
||||
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
} else {
|
||||
Text("Connect (Manual)")
|
||||
}
|
||||
}
|
||||
.disabled(self.connectingGatewayID != nil || self.manualGatewayHost
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty || !self.manualPortIsValid)
|
||||
|
||||
Text(
|
||||
"Use this when mDNS/Bonjour discovery is blocked. "
|
||||
+ "Leave port empty for 443 on tailnet DNS (TLS) or 18789 otherwise.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Toggle("Discovery Debug Logs", isOn: self.$discoveryDebugLogsEnabled)
|
||||
.onChange(of: self.discoveryDebugLogsEnabled) { _, newValue in
|
||||
self.gatewayController.setDiscoveryDebugLoggingEnabled(newValue)
|
||||
}
|
||||
|
||||
NavigationLink("Discovery Logs") {
|
||||
GatewayDiscoveryDebugLogView()
|
||||
}
|
||||
|
||||
Toggle("Debug Canvas Status", isOn: self.$canvasDebugStatusEnabled)
|
||||
|
||||
TextField("Gateway Token", text: self.$gatewayToken)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
|
||||
SecureField("Gateway Password", text: self.$gatewayPassword)
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("Debug")
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
Text(self.gatewayDebugText())
|
||||
.font(.system(size: 12, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Circle()
|
||||
@@ -251,20 +235,6 @@ struct SettingsTab: View {
|
||||
.onChange(of: self.talkEnabled) { _, newValue in
|
||||
self.appModel.setTalkEnabled(newValue)
|
||||
}
|
||||
SecureField("Talk ElevenLabs API Key (optional)", text: self.$talkElevenLabsApiKey)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled()
|
||||
Text("Use this local override when gateway config redacts talk.apiKey for mobile clients.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Toggle("Background Listening", isOn: self.$talkBackgroundEnabled)
|
||||
Text("Keep listening when the app is in the background. Uses more battery.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
Toggle("Voice Directive Hint", isOn: self.$talkVoiceDirectiveHintEnabled)
|
||||
Text("Include ElevenLabs voice switching instructions in the Talk Mode prompt. Disable to save tokens.")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
// Keep this separate so users can hide the side bubble without disabling Talk Mode.
|
||||
Toggle("Show Talk Button", isOn: self.$talkButtonEnabled)
|
||||
|
||||
@@ -333,15 +303,6 @@ struct SettingsTab: View {
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
}
|
||||
.alert("Reset Onboarding?", isPresented: self.$showResetOnboardingAlert) {
|
||||
Button("Reset", role: .destructive) {
|
||||
self.resetOnboarding()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text(
|
||||
"This will disconnect, clear saved gateway connection + credentials, and reopen the onboarding wizard.")
|
||||
}
|
||||
.onAppear {
|
||||
self.localIPAddress = NetworkInterfaces.primaryIPv4Address()
|
||||
self.lastLocationModeRaw = self.locationEnabledModeRaw
|
||||
@@ -351,7 +312,6 @@ struct SettingsTab: View {
|
||||
self.gatewayToken = GatewaySettingsStore.loadGatewayToken(instanceId: trimmedInstanceId) ?? ""
|
||||
self.gatewayPassword = GatewaySettingsStore.loadGatewayPassword(instanceId: trimmedInstanceId) ?? ""
|
||||
}
|
||||
self.talkElevenLabsApiKey = GatewaySettingsStore.loadTalkElevenLabsApiKey() ?? ""
|
||||
// Keep setup front-and-center when disconnected; keep things compact once connected.
|
||||
self.gatewayExpanded = !self.isGatewayConnected
|
||||
self.selectedAgentPickerId = self.appModel.selectedAgentId ?? ""
|
||||
@@ -371,22 +331,17 @@ struct SettingsTab: View {
|
||||
GatewaySettingsStore.savePreferredGatewayStableID(trimmed)
|
||||
}
|
||||
.onChange(of: self.gatewayToken) { _, newValue in
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayToken(trimmed, instanceId: instanceId)
|
||||
}
|
||||
.onChange(of: self.gatewayPassword) { _, newValue in
|
||||
guard !self.suppressCredentialPersist else { return }
|
||||
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let instanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !instanceId.isEmpty else { return }
|
||||
GatewaySettingsStore.saveGatewayPassword(trimmed, instanceId: instanceId)
|
||||
}
|
||||
.onChange(of: self.talkElevenLabsApiKey) { _, newValue in
|
||||
GatewaySettingsStore.saveTalkElevenLabsApiKey(newValue)
|
||||
}
|
||||
.onChange(of: self.manualGatewayPort) { _, _ in
|
||||
self.syncManualPortText()
|
||||
}
|
||||
@@ -466,11 +421,10 @@ struct SettingsTab: View {
|
||||
ForEach(rows) { gateway in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
// Avoid localized-string formatting edge cases from Bonjour-advertised names.
|
||||
Text(verbatim: gateway.name)
|
||||
Text(gateway.name)
|
||||
let detailLines = self.gatewayDetailLines(gateway)
|
||||
ForEach(detailLines, id: \.self) { line in
|
||||
Text(verbatim: line)
|
||||
Text(line)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
@@ -556,10 +510,7 @@ struct SettingsTab: View {
|
||||
GatewaySettingsStore.saveLastDiscoveredGatewayStableID(gateway.stableID)
|
||||
defer { self.connectingGatewayID = nil }
|
||||
|
||||
let err = await self.gatewayController.connectWithDiagnostics(gateway)
|
||||
if let err {
|
||||
self.setupStatusText = err
|
||||
}
|
||||
await self.gatewayController.connect(gateway)
|
||||
}
|
||||
|
||||
private func connectLastKnown() async {
|
||||
@@ -898,43 +849,6 @@ struct SettingsTab: View {
|
||||
SettingsNetworkingHelpers.httpURLString(host: host, port: port, fallback: fallback)
|
||||
}
|
||||
|
||||
private func resetOnboarding() {
|
||||
// Disconnect first so RootCanvas doesn't instantly mark onboarding complete again.
|
||||
self.appModel.disconnectGateway()
|
||||
self.connectingGatewayID = nil
|
||||
self.setupStatusText = nil
|
||||
self.setupCode = ""
|
||||
self.gatewayAutoConnect = false
|
||||
|
||||
self.suppressCredentialPersist = true
|
||||
defer { self.suppressCredentialPersist = false }
|
||||
|
||||
self.gatewayToken = ""
|
||||
self.gatewayPassword = ""
|
||||
|
||||
let trimmedInstanceId = self.instanceId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedInstanceId.isEmpty {
|
||||
GatewaySettingsStore.deleteGatewayCredentials(instanceId: trimmedInstanceId)
|
||||
}
|
||||
|
||||
// Reset onboarding state + clear saved gateway connection (the two things RootCanvas checks).
|
||||
GatewaySettingsStore.clearLastGatewayConnection()
|
||||
|
||||
// RootCanvas also short-circuits onboarding when these are true.
|
||||
self.onboardingComplete = false
|
||||
self.hasConnectedOnce = false
|
||||
|
||||
// Clear manual override so it doesn't count as an existing gateway config.
|
||||
self.manualGatewayEnabled = false
|
||||
self.manualGatewayHost = ""
|
||||
|
||||
// Force re-present even without app restart.
|
||||
self.onboardingRequestID += 1
|
||||
|
||||
// The onboarding wizard is presented from RootCanvas; dismiss Settings so it can show.
|
||||
self.dismiss()
|
||||
}
|
||||
|
||||
private func gatewayDetailLines(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> [String] {
|
||||
var lines: [String] = []
|
||||
if let lanHost = gateway.lanHost { lines.append("LAN: \(lanHost)") }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
enum StatusActivityBuilder {
|
||||
@MainActor
|
||||
static func build(
|
||||
appModel: NodeAppModel,
|
||||
voiceWakeEnabled: Bool,
|
||||
|
||||
@@ -2,8 +2,6 @@ import SwiftUI
|
||||
|
||||
struct StatusPill: View {
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
enum GatewayState: Equatable {
|
||||
case connected
|
||||
@@ -51,11 +49,11 @@ struct StatusPill: View {
|
||||
Circle()
|
||||
.fill(self.gateway.color)
|
||||
.frame(width: 9, height: 9)
|
||||
.scaleEffect(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.15 : 0.85) : 1.0)
|
||||
.opacity(self.gateway == .connecting && !self.reduceMotion ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||
.scaleEffect(self.gateway == .connecting ? (self.pulse ? 1.15 : 0.85) : 1.0)
|
||||
.opacity(self.gateway == .connecting ? (self.pulse ? 1.0 : 0.6) : 1.0)
|
||||
|
||||
Text(self.gateway.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
@@ -66,17 +64,17 @@ struct StatusPill: View {
|
||||
if let activity {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: activity.systemImage)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(activity.tint ?? .primary)
|
||||
Text(activity.title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
} else {
|
||||
Image(systemName: self.voiceWakeEnabled ? "mic.fill" : "mic.slash")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(self.voiceWakeEnabled ? .primary : .secondary)
|
||||
.accessibilityLabel(self.voiceWakeEnabled ? "Voice Wake enabled" : "Voice Wake disabled")
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
@@ -89,28 +87,21 @@ struct StatusPill: View {
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(
|
||||
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : 0.5
|
||||
)
|
||||
.strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 0.5)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Connection Status")
|
||||
.accessibilityLabel("Status")
|
||||
.accessibilityValue(self.accessibilityValue)
|
||||
.accessibilityHint("Double tap to open settings")
|
||||
.onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion) }
|
||||
.onAppear { self.updatePulse(for: self.gateway, scenePhase: self.scenePhase) }
|
||||
.onDisappear { self.pulse = false }
|
||||
.onChange(of: self.gateway) { _, newValue in
|
||||
self.updatePulse(for: newValue, scenePhase: self.scenePhase, reduceMotion: self.reduceMotion)
|
||||
self.updatePulse(for: newValue, scenePhase: self.scenePhase)
|
||||
}
|
||||
.onChange(of: self.scenePhase) { _, newValue in
|
||||
self.updatePulse(for: self.gateway, scenePhase: newValue, reduceMotion: self.reduceMotion)
|
||||
}
|
||||
.onChange(of: self.reduceMotion) { _, newValue in
|
||||
self.updatePulse(for: self.gateway, scenePhase: self.scenePhase, reduceMotion: newValue)
|
||||
self.updatePulse(for: self.gateway, scenePhase: newValue)
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.18), value: self.activity?.title)
|
||||
}
|
||||
@@ -122,9 +113,9 @@ struct StatusPill: View {
|
||||
return "\(self.gateway.title), Voice Wake \(self.voiceWakeEnabled ? "enabled" : "disabled")"
|
||||
}
|
||||
|
||||
private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase, reduceMotion: Bool) {
|
||||
guard gateway == .connecting, scenePhase == .active, !reduceMotion else {
|
||||
withAnimation(reduceMotion ? .none : .easeOut(duration: 0.2)) { self.pulse = false }
|
||||
private func updatePulse(for gateway: GatewayState, scenePhase: ScenePhase) {
|
||||
guard gateway == .connecting, scenePhase == .active else {
|
||||
withAnimation(.easeOut(duration: 0.2)) { self.pulse = false }
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceWakeToast: View {
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
var command: String
|
||||
var brighten: Bool = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "mic.fill")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(self.command)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
@@ -25,14 +23,11 @@ struct VoiceWakeToast: View {
|
||||
.fill(.ultraThinMaterial)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||||
.strokeBorder(
|
||||
.white.opacity(self.contrast == .increased ? 0.5 : (self.brighten ? 0.24 : 0.18)),
|
||||
lineWidth: self.contrast == .increased ? 1.0 : 0.5
|
||||
)
|
||||
.strokeBorder(.white.opacity(self.brighten ? 0.24 : 0.18), lineWidth: 0.5)
|
||||
}
|
||||
.shadow(color: .black.opacity(0.25), radius: 12, y: 6)
|
||||
}
|
||||
.accessibilityLabel("Voice Wake triggered")
|
||||
.accessibilityValue("Command: \(self.command)")
|
||||
.accessibilityLabel("Voice Wake")
|
||||
.accessibilityValue(self.command)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import Speech
|
||||
final class TalkModeManager: NSObject {
|
||||
private typealias SpeechRequest = SFSpeechAudioBufferRecognitionRequest
|
||||
private static let defaultModelIdFallback = "eleven_v3"
|
||||
private static let redactedConfigSentinel = "__OPENCLAW_REDACTED__"
|
||||
var isEnabled: Bool = false
|
||||
var isListening: Bool = false
|
||||
var isSpeaking: Bool = false
|
||||
@@ -219,12 +218,8 @@ final class TalkModeManager: NSObject {
|
||||
|
||||
/// Suspends microphone usage without disabling Talk Mode.
|
||||
/// Used when the app backgrounds (or when we need to temporarily release the mic).
|
||||
func suspendForBackground(keepActive: Bool = false) -> Bool {
|
||||
func suspendForBackground() -> Bool {
|
||||
guard self.isEnabled else { return false }
|
||||
if keepActive {
|
||||
self.statusText = self.isListening ? "Listening" : self.statusText
|
||||
return false
|
||||
}
|
||||
let wasActive = self.isListening || self.isSpeaking || self.isPushToTalkActive
|
||||
|
||||
self.isListening = false
|
||||
@@ -251,8 +246,7 @@ final class TalkModeManager: NSObject {
|
||||
return wasActive
|
||||
}
|
||||
|
||||
func resumeAfterBackground(wasSuspended: Bool, wasKeptActive: Bool = false) async {
|
||||
if wasKeptActive { return }
|
||||
func resumeAfterBackground(wasSuspended: Bool) async {
|
||||
guard wasSuspended else { return }
|
||||
guard self.isEnabled else { return }
|
||||
await self.start()
|
||||
@@ -820,24 +814,29 @@ final class TalkModeManager: NSObject {
|
||||
private func subscribeChatIfNeeded(sessionKey: String) async {
|
||||
let key = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { return }
|
||||
guard let gateway else { return }
|
||||
guard !self.chatSubscribedSessionKeys.contains(key) else { return }
|
||||
|
||||
// Operator clients receive chat events without node-style subscriptions.
|
||||
let payload = "{\"sessionKey\":\"\(key)\"}"
|
||||
await gateway.sendEvent(event: "chat.subscribe", payloadJSON: payload)
|
||||
self.chatSubscribedSessionKeys.insert(key)
|
||||
self.logger.info("chat.subscribe ok sessionKey=\(key, privacy: .public)")
|
||||
}
|
||||
|
||||
private func unsubscribeAllChats() async {
|
||||
guard let gateway else { return }
|
||||
let keys = self.chatSubscribedSessionKeys
|
||||
self.chatSubscribedSessionKeys.removeAll()
|
||||
for key in keys {
|
||||
let payload = "{\"sessionKey\":\"\(key)\"}"
|
||||
await gateway.sendEvent(event: "chat.unsubscribe", payloadJSON: payload)
|
||||
}
|
||||
}
|
||||
|
||||
private func buildPrompt(transcript: String) -> String {
|
||||
let interrupted = self.lastInterruptedAtSeconds
|
||||
self.lastInterruptedAtSeconds = nil
|
||||
let includeVoiceDirectiveHint = (UserDefaults.standard.object(forKey: "talk.voiceDirectiveHint.enabled") as? Bool) ?? true
|
||||
return TalkPromptBuilder.build(
|
||||
transcript: transcript,
|
||||
interruptedAtSeconds: interrupted,
|
||||
includeVoiceDirectiveHint: includeVoiceDirectiveHint)
|
||||
return TalkPromptBuilder.build(transcript: transcript, interruptedAtSeconds: interrupted)
|
||||
}
|
||||
|
||||
private enum ChatCompletionState: CustomStringConvertible {
|
||||
@@ -1115,7 +1114,6 @@ final class TalkModeManager: NSObject {
|
||||
}
|
||||
|
||||
private func shouldInterrupt(with transcript: String) -> Bool {
|
||||
guard self.shouldAllowSpeechInterruptForCurrentRoute() else { return false }
|
||||
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count >= 3 else { return false }
|
||||
if let spoken = self.lastSpokenText?.lowercased(), spoken.contains(trimmed.lowercased()) {
|
||||
@@ -1124,20 +1122,6 @@ final class TalkModeManager: NSObject {
|
||||
return true
|
||||
}
|
||||
|
||||
private func shouldAllowSpeechInterruptForCurrentRoute() -> Bool {
|
||||
let route = AVAudioSession.sharedInstance().currentRoute
|
||||
// Built-in speaker/receiver often feeds TTS back into STT, causing false interrupts.
|
||||
// Allow barge-in for isolated outputs (headphones/Bluetooth/USB/CarPlay/AirPlay).
|
||||
return !route.outputs.contains { output in
|
||||
switch output.portType {
|
||||
case .builtInSpeaker, .builtInReceiver:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func shouldUseIncrementalTTS() -> Bool {
|
||||
true
|
||||
}
|
||||
@@ -1684,15 +1668,6 @@ extension TalkModeManager {
|
||||
return value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }
|
||||
}
|
||||
|
||||
private static func normalizedTalkApiKey(_ raw: String?) -> String? {
|
||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
guard trimmed != Self.redactedConfigSentinel else { return nil }
|
||||
// Config values may be env placeholders (for example `${ELEVENLABS_API_KEY}`).
|
||||
if trimmed.hasPrefix("${"), trimmed.hasSuffix("}") { return nil }
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func reloadConfig() async {
|
||||
guard let gateway else { return }
|
||||
do {
|
||||
@@ -1724,15 +1699,7 @@ extension TalkModeManager {
|
||||
}
|
||||
self.defaultOutputFormat = (talk?["outputFormat"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let rawConfigApiKey = (talk?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let configApiKey = Self.normalizedTalkApiKey(rawConfigApiKey)
|
||||
let localApiKey = Self.normalizedTalkApiKey(GatewaySettingsStore.loadTalkElevenLabsApiKey())
|
||||
if rawConfigApiKey == Self.redactedConfigSentinel {
|
||||
self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : nil
|
||||
GatewayDiagnostics.log("talk config apiKey redacted; using local override if present")
|
||||
} else {
|
||||
self.apiKey = (localApiKey?.isEmpty == false) ? localApiKey : configApiKey
|
||||
}
|
||||
self.apiKey = (talk?["apiKey"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let interrupt = talk?["interruptOnSpeech"] as? Bool {
|
||||
self.interruptOnSpeech = interrupt
|
||||
}
|
||||
|
||||
@@ -76,52 +76,4 @@ import Testing
|
||||
timeoutSeconds: nil,
|
||||
key: nil)))
|
||||
}
|
||||
|
||||
@Test func parseGatewayLinkParsesCommonFields() {
|
||||
let url = URL(
|
||||
string: "openclaw://gateway?host=openclaw.local&port=18789&tls=1&token=abc&password=def")!
|
||||
#expect(
|
||||
DeepLinkParser.parse(url) == .gateway(
|
||||
.init(host: "openclaw.local", port: 18789, tls: true, token: "abc", password: "def")))
|
||||
}
|
||||
|
||||
@Test func parseGatewaySetupCodeParsesBase64UrlPayload() {
|
||||
let payload = #"{"url":"wss://gateway.example.com:443","token":"tok","password":"pw"}"#
|
||||
let encoded = Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
|
||||
|
||||
#expect(link == .init(
|
||||
host: "gateway.example.com",
|
||||
port: 443,
|
||||
tls: true,
|
||||
token: "tok",
|
||||
password: "pw"))
|
||||
}
|
||||
|
||||
@Test func parseGatewaySetupCodeRejectsInvalidInput() {
|
||||
#expect(GatewayConnectDeepLink.fromSetupCode("not-a-valid-setup-code") == nil)
|
||||
}
|
||||
|
||||
@Test func parseGatewaySetupCodeDefaultsTo443ForWssWithoutPort() {
|
||||
let payload = #"{"url":"wss://gateway.example.com","token":"tok"}"#
|
||||
let encoded = Data(payload.utf8)
|
||||
.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
|
||||
let link = GatewayConnectDeepLink.fromSetupCode(encoded)
|
||||
|
||||
#expect(link == .init(
|
||||
host: "gateway.example.com",
|
||||
port: 443,
|
||||
tls: true,
|
||||
token: "tok",
|
||||
password: nil))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,47 +76,4 @@ private func withUserDefaults<T>(_ updates: [String: Any?], _ body: () throws ->
|
||||
#expect(commands.contains(OpenClawLocationCommand.get.rawValue))
|
||||
}
|
||||
}
|
||||
@Test @MainActor func currentCommandsExcludeDangerousSystemExecCommands() {
|
||||
withUserDefaults([
|
||||
"node.instanceId": "ios-test",
|
||||
"camera.enabled": true,
|
||||
"location.enabledMode": OpenClawLocationMode.whileUsing.rawValue,
|
||||
]) {
|
||||
let appModel = NodeAppModel()
|
||||
let controller = GatewayConnectionController(appModel: appModel, startDiscovery: false)
|
||||
let commands = Set(controller._test_currentCommands())
|
||||
|
||||
// iOS should expose notify, but not host shell/exec-approval commands.
|
||||
#expect(commands.contains(OpenClawSystemCommand.notify.rawValue))
|
||||
#expect(!commands.contains(OpenClawSystemCommand.run.rawValue))
|
||||
#expect(!commands.contains(OpenClawSystemCommand.which.rawValue))
|
||||
#expect(!commands.contains(OpenClawSystemCommand.execApprovalsGet.rawValue))
|
||||
#expect(!commands.contains(OpenClawSystemCommand.execApprovalsSet.rawValue))
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReadsSavedValues() {
|
||||
withUserDefaults([:]) {
|
||||
GatewaySettingsStore.saveLastGatewayConnectionManual(
|
||||
host: "gateway.example.com",
|
||||
port: 443,
|
||||
useTLS: true,
|
||||
stableID: "manual|gateway.example.com|443")
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == .manual(host: "gateway.example.com", port: 443, useTLS: true, stableID: "manual|gateway.example.com|443"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test @MainActor func loadLastConnectionReturnsNilForInvalidData() {
|
||||
withUserDefaults([
|
||||
"gateway.last.kind": "manual",
|
||||
"gateway.last.host": "",
|
||||
"gateway.last.port": 0,
|
||||
"gateway.last.tls": false,
|
||||
"gateway.last.stableID": "manual|invalid|0",
|
||||
]) {
|
||||
let loaded = GatewaySettingsStore.loadLastGatewayConnection()
|
||||
#expect(loaded == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct GatewayConnectionIssueTests {
|
||||
@Test func detectsTokenMissing() {
|
||||
let issue = GatewayConnectionIssue.detect(from: "unauthorized: gateway token missing")
|
||||
#expect(issue == .tokenMissing)
|
||||
#expect(issue.needsAuthToken)
|
||||
}
|
||||
|
||||
@Test func detectsUnauthorized() {
|
||||
let issue = GatewayConnectionIssue.detect(from: "Gateway error: unauthorized role")
|
||||
#expect(issue == .unauthorized)
|
||||
#expect(issue.needsAuthToken)
|
||||
}
|
||||
|
||||
@Test func detectsPairingWithRequestId() {
|
||||
let issue = GatewayConnectionIssue.detect(from: "pairing required (requestId: abc123)")
|
||||
#expect(issue == .pairingRequired(requestId: "abc123"))
|
||||
#expect(issue.needsPairing)
|
||||
#expect(issue.requestId == "abc123")
|
||||
}
|
||||
|
||||
@Test func detectsNetworkError() {
|
||||
let issue = GatewayConnectionIssue.detect(from: "Gateway error: Connection refused")
|
||||
#expect(issue == .network)
|
||||
}
|
||||
|
||||
@Test func returnsNoneForBenignStatus() {
|
||||
let issue = GatewayConnectionIssue.detect(from: "Connected")
|
||||
#expect(issue == .none)
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClaw
|
||||
|
||||
@Suite(.serialized) struct OnboardingStateStoreTests {
|
||||
@Test @MainActor func shouldPresentWhenFreshAndDisconnected() {
|
||||
let testDefaults = self.makeDefaults()
|
||||
let defaults = testDefaults.defaults
|
||||
defer { self.reset(testDefaults) }
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayServerName = nil
|
||||
#expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
|
||||
}
|
||||
|
||||
@Test @MainActor func doesNotPresentWhenConnected() {
|
||||
let testDefaults = self.makeDefaults()
|
||||
let defaults = testDefaults.defaults
|
||||
defer { self.reset(testDefaults) }
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayServerName = "gateway"
|
||||
#expect(!OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
|
||||
}
|
||||
|
||||
@Test @MainActor func markCompletedPersistsMode() {
|
||||
let testDefaults = self.makeDefaults()
|
||||
let defaults = testDefaults.defaults
|
||||
defer { self.reset(testDefaults) }
|
||||
|
||||
let appModel = NodeAppModel()
|
||||
appModel.gatewayServerName = nil
|
||||
|
||||
OnboardingStateStore.markCompleted(mode: .remoteDomain, defaults: defaults)
|
||||
#expect(OnboardingStateStore.lastMode(defaults: defaults) == .remoteDomain)
|
||||
#expect(!OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
|
||||
|
||||
OnboardingStateStore.markIncomplete(defaults: defaults)
|
||||
#expect(OnboardingStateStore.shouldPresentOnLaunch(appModel: appModel, defaults: defaults))
|
||||
}
|
||||
|
||||
private struct TestDefaults {
|
||||
var suiteName: String
|
||||
var defaults: UserDefaults
|
||||
}
|
||||
|
||||
private func makeDefaults() -> TestDefaults {
|
||||
let suiteName = "OnboardingStateStoreTests.\(UUID().uuidString)"
|
||||
return TestDefaults(
|
||||
suiteName: suiteName,
|
||||
defaults: UserDefaults(suiteName: suiteName) ?? .standard)
|
||||
}
|
||||
|
||||
private func reset(_ defaults: TestDefaults) {
|
||||
defaults.defaults.removePersistentDomain(forName: defaults.suiteName)
|
||||
}
|
||||
}
|
||||
@@ -2084,7 +2084,6 @@ public struct SkillsUpdateParams: Codable, Sendable {
|
||||
public struct CronJob: Codable, Sendable {
|
||||
public let id: String
|
||||
public let agentid: String?
|
||||
public let sessionkey: String?
|
||||
public let name: String
|
||||
public let description: String?
|
||||
public let enabled: Bool
|
||||
@@ -2101,7 +2100,6 @@ public struct CronJob: Codable, Sendable {
|
||||
public init(
|
||||
id: String,
|
||||
agentid: String?,
|
||||
sessionkey: String?,
|
||||
name: String,
|
||||
description: String?,
|
||||
enabled: Bool,
|
||||
@@ -2117,7 +2115,6 @@ public struct CronJob: Codable, Sendable {
|
||||
) {
|
||||
self.id = id
|
||||
self.agentid = agentid
|
||||
self.sessionkey = sessionkey
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.enabled = enabled
|
||||
@@ -2134,7 +2131,6 @@ public struct CronJob: Codable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case agentid = "agentId"
|
||||
case sessionkey = "sessionKey"
|
||||
case name
|
||||
case description
|
||||
case enabled
|
||||
@@ -2169,7 +2165,6 @@ public struct CronStatusParams: Codable, Sendable {
|
||||
public struct CronAddParams: Codable, Sendable {
|
||||
public let name: String
|
||||
public let agentid: AnyCodable?
|
||||
public let sessionkey: AnyCodable?
|
||||
public let description: String?
|
||||
public let enabled: Bool?
|
||||
public let deleteafterrun: Bool?
|
||||
@@ -2182,7 +2177,6 @@ public struct CronAddParams: Codable, Sendable {
|
||||
public init(
|
||||
name: String,
|
||||
agentid: AnyCodable?,
|
||||
sessionkey: AnyCodable?,
|
||||
description: String?,
|
||||
enabled: Bool?,
|
||||
deleteafterrun: Bool?,
|
||||
@@ -2194,7 +2188,6 @@ public struct CronAddParams: Codable, Sendable {
|
||||
) {
|
||||
self.name = name
|
||||
self.agentid = agentid
|
||||
self.sessionkey = sessionkey
|
||||
self.description = description
|
||||
self.enabled = enabled
|
||||
self.deleteafterrun = deleteafterrun
|
||||
@@ -2207,7 +2200,6 @@ public struct CronAddParams: Codable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case agentid = "agentId"
|
||||
case sessionkey = "sessionKey"
|
||||
case description
|
||||
case enabled
|
||||
case deleteafterrun = "deleteAfterRun"
|
||||
@@ -2765,144 +2757,6 @@ public struct ChatEvent: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct MeshPlanParams: Codable, Sendable {
|
||||
public let goal: String
|
||||
public let steps: [[String: AnyCodable]]?
|
||||
|
||||
public init(
|
||||
goal: String,
|
||||
steps: [[String: AnyCodable]]?
|
||||
) {
|
||||
self.goal = goal
|
||||
self.steps = steps
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case goal
|
||||
case steps
|
||||
}
|
||||
}
|
||||
|
||||
public struct MeshPlanAutoParams: Codable, Sendable {
|
||||
public let goal: String
|
||||
public let maxsteps: Int?
|
||||
public let agentid: String?
|
||||
public let sessionkey: String?
|
||||
public let thinking: String?
|
||||
public let timeoutms: Int?
|
||||
public let lane: String?
|
||||
|
||||
public init(
|
||||
goal: String,
|
||||
maxsteps: Int?,
|
||||
agentid: String?,
|
||||
sessionkey: String?,
|
||||
thinking: String?,
|
||||
timeoutms: Int?,
|
||||
lane: String?
|
||||
) {
|
||||
self.goal = goal
|
||||
self.maxsteps = maxsteps
|
||||
self.agentid = agentid
|
||||
self.sessionkey = sessionkey
|
||||
self.thinking = thinking
|
||||
self.timeoutms = timeoutms
|
||||
self.lane = lane
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case goal
|
||||
case maxsteps = "maxSteps"
|
||||
case agentid = "agentId"
|
||||
case sessionkey = "sessionKey"
|
||||
case thinking
|
||||
case timeoutms = "timeoutMs"
|
||||
case lane
|
||||
}
|
||||
}
|
||||
|
||||
public struct MeshWorkflowPlan: Codable, Sendable {
|
||||
public let planid: String
|
||||
public let goal: String
|
||||
public let createdat: Int
|
||||
public let steps: [[String: AnyCodable]]
|
||||
|
||||
public init(
|
||||
planid: String,
|
||||
goal: String,
|
||||
createdat: Int,
|
||||
steps: [[String: AnyCodable]]
|
||||
) {
|
||||
self.planid = planid
|
||||
self.goal = goal
|
||||
self.createdat = createdat
|
||||
self.steps = steps
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case planid = "planId"
|
||||
case goal
|
||||
case createdat = "createdAt"
|
||||
case steps
|
||||
}
|
||||
}
|
||||
|
||||
public struct MeshRunParams: Codable, Sendable {
|
||||
public let plan: MeshWorkflowPlan
|
||||
public let continueonerror: Bool?
|
||||
public let maxparallel: Int?
|
||||
public let defaultsteptimeoutms: Int?
|
||||
public let lane: String?
|
||||
|
||||
public init(
|
||||
plan: MeshWorkflowPlan,
|
||||
continueonerror: Bool?,
|
||||
maxparallel: Int?,
|
||||
defaultsteptimeoutms: Int?,
|
||||
lane: String?
|
||||
) {
|
||||
self.plan = plan
|
||||
self.continueonerror = continueonerror
|
||||
self.maxparallel = maxparallel
|
||||
self.defaultsteptimeoutms = defaultsteptimeoutms
|
||||
self.lane = lane
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case plan
|
||||
case continueonerror = "continueOnError"
|
||||
case maxparallel = "maxParallel"
|
||||
case defaultsteptimeoutms = "defaultStepTimeoutMs"
|
||||
case lane
|
||||
}
|
||||
}
|
||||
|
||||
public struct MeshStatusParams: Codable, Sendable {
|
||||
public let runid: String
|
||||
|
||||
public init(
|
||||
runid: String
|
||||
) {
|
||||
self.runid = runid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case runid = "runId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct MeshRetryParams: Codable, Sendable {
|
||||
public let runid: String
|
||||
public let stepids: [String]?
|
||||
|
||||
public init(
|
||||
runid: String,
|
||||
stepids: [String]?
|
||||
) {
|
||||
self.runid = runid
|
||||
self.stepids = stepids
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case runid = "runId"
|
||||
case stepids = "stepIds"
|
||||
}
|
||||
}
|
||||
|
||||
public struct UpdateRunParams: Codable, Sendable {
|
||||
public let sessionkey: String?
|
||||
public let note: String?
|
||||
|
||||
@@ -170,9 +170,7 @@ public final class OpenClawChatViewModel {
|
||||
}
|
||||
|
||||
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
|
||||
self.messages = Self.reconcileMessageIDs(
|
||||
previous: self.messages,
|
||||
incoming: Self.decodeMessages(payload.messages ?? []))
|
||||
self.messages = Self.decodeMessages(payload.messages ?? [])
|
||||
self.sessionId = payload.sessionId
|
||||
if let level = payload.thinkingLevel, !level.isEmpty {
|
||||
self.thinkingLevel = level
|
||||
@@ -193,70 +191,6 @@ public final class OpenClawChatViewModel {
|
||||
return Self.dedupeMessages(decoded)
|
||||
}
|
||||
|
||||
private static func messageIdentityKey(for message: OpenClawChatMessage) -> String? {
|
||||
let role = message.role.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
guard !role.isEmpty else { return nil }
|
||||
|
||||
let timestamp: String = {
|
||||
guard let value = message.timestamp, value.isFinite else { return "" }
|
||||
return String(format: "%.3f", value)
|
||||
}()
|
||||
|
||||
let contentFingerprint = message.content.map { item in
|
||||
let type = (item.type ?? "text").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let text = (item.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let id = (item.id ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let name = (item.name ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let fileName = (item.fileName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return [type, text, id, name, fileName].joined(separator: "\\u{001F}")
|
||||
}.joined(separator: "\\u{001E}")
|
||||
|
||||
let toolCallId = (message.toolCallId ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let toolName = (message.toolName ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if timestamp.isEmpty, contentFingerprint.isEmpty, toolCallId.isEmpty, toolName.isEmpty {
|
||||
return nil
|
||||
}
|
||||
return [role, timestamp, toolCallId, toolName, contentFingerprint].joined(separator: "|")
|
||||
}
|
||||
|
||||
private static func reconcileMessageIDs(
|
||||
previous: [OpenClawChatMessage],
|
||||
incoming: [OpenClawChatMessage]) -> [OpenClawChatMessage]
|
||||
{
|
||||
guard !previous.isEmpty, !incoming.isEmpty else { return incoming }
|
||||
|
||||
var idsByKey: [String: [UUID]] = [:]
|
||||
for message in previous {
|
||||
guard let key = Self.messageIdentityKey(for: message) else { continue }
|
||||
idsByKey[key, default: []].append(message.id)
|
||||
}
|
||||
|
||||
return incoming.map { message in
|
||||
guard let key = Self.messageIdentityKey(for: message),
|
||||
var ids = idsByKey[key],
|
||||
let reusedId = ids.first
|
||||
else {
|
||||
return message
|
||||
}
|
||||
ids.removeFirst()
|
||||
if ids.isEmpty {
|
||||
idsByKey.removeValue(forKey: key)
|
||||
} else {
|
||||
idsByKey[key] = ids
|
||||
}
|
||||
guard reusedId != message.id else { return message }
|
||||
return OpenClawChatMessage(
|
||||
id: reusedId,
|
||||
role: message.role,
|
||||
content: message.content,
|
||||
timestamp: message.timestamp,
|
||||
toolCallId: message.toolCallId,
|
||||
toolName: message.toolName,
|
||||
usage: message.usage,
|
||||
stopReason: message.stopReason)
|
||||
}
|
||||
}
|
||||
|
||||
private static func dedupeMessages(_ messages: [OpenClawChatMessage]) -> [OpenClawChatMessage] {
|
||||
var result: [OpenClawChatMessage] = []
|
||||
result.reserveCapacity(messages.count)
|
||||
@@ -441,15 +375,11 @@ public final class OpenClawChatViewModel {
|
||||
}
|
||||
|
||||
private func handleChatEvent(_ chat: OpenClawChatEventPayload) {
|
||||
let isOurRun = chat.runId.flatMap { self.pendingRuns.contains($0) } ?? false
|
||||
|
||||
// Gateway may publish canonical session keys (for example "agent:main:main")
|
||||
// even when this view currently uses an alias key (for example "main").
|
||||
// Never drop events for our own pending run on key mismatch, or the UI can stay
|
||||
// stuck at "thinking" until the user reopens and forces a history reload.
|
||||
if let sessionKey = chat.sessionKey, sessionKey != self.sessionKey, !isOurRun {
|
||||
if let sessionKey = chat.sessionKey, sessionKey != self.sessionKey {
|
||||
return
|
||||
}
|
||||
|
||||
let isOurRun = chat.runId.flatMap { self.pendingRuns.contains($0) } ?? false
|
||||
if !isOurRun {
|
||||
// Keep multiple clients in sync: if another client finishes a run for our session, refresh history.
|
||||
switch chat.state {
|
||||
@@ -514,9 +444,7 @@ public final class OpenClawChatViewModel {
|
||||
private func refreshHistoryAfterRun() async {
|
||||
do {
|
||||
let payload = try await self.transport.requestHistory(sessionKey: self.sessionKey)
|
||||
self.messages = Self.reconcileMessageIDs(
|
||||
previous: self.messages,
|
||||
incoming: Self.decodeMessages(payload.messages ?? []))
|
||||
self.messages = Self.decodeMessages(payload.messages ?? [])
|
||||
self.sessionId = payload.sessionId
|
||||
if let level = payload.thinkingLevel, !level.isEmpty {
|
||||
self.thinkingLevel = level
|
||||
|
||||
@@ -2,56 +2,6 @@ import Foundation
|
||||
|
||||
public enum DeepLinkRoute: Sendable, Equatable {
|
||||
case agent(AgentDeepLink)
|
||||
case gateway(GatewayConnectDeepLink)
|
||||
}
|
||||
|
||||
public struct GatewayConnectDeepLink: Codable, Sendable, Equatable {
|
||||
public let host: String
|
||||
public let port: Int
|
||||
public let tls: Bool
|
||||
public let token: String?
|
||||
public let password: String?
|
||||
|
||||
public init(host: String, port: Int, tls: Bool, token: String?, password: String?) {
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.tls = tls
|
||||
self.token = token
|
||||
self.password = password
|
||||
}
|
||||
|
||||
public var websocketURL: URL? {
|
||||
let scheme = self.tls ? "wss" : "ws"
|
||||
return URL(string: "\(scheme)://\(self.host):\(self.port)")
|
||||
}
|
||||
|
||||
/// Parse a device-pair setup code (base64url-encoded JSON: `{url, token?, password?}`).
|
||||
public static func fromSetupCode(_ code: String) -> GatewayConnectDeepLink? {
|
||||
guard let data = Self.decodeBase64Url(code) else { return nil }
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
||||
guard let urlString = json["url"] as? String,
|
||||
let parsed = URLComponents(string: urlString),
|
||||
let hostname = parsed.host, !hostname.isEmpty
|
||||
else { return nil }
|
||||
|
||||
let scheme = (parsed.scheme ?? "ws").lowercased()
|
||||
let tls = scheme == "wss"
|
||||
let port = parsed.port ?? (tls ? 443 : 18789)
|
||||
let token = json["token"] as? String
|
||||
let password = json["password"] as? String
|
||||
return GatewayConnectDeepLink(host: hostname, port: port, tls: tls, token: token, password: password)
|
||||
}
|
||||
|
||||
private static func decodeBase64Url(_ input: String) -> Data? {
|
||||
var base64 = input
|
||||
.replacingOccurrences(of: "-", with: "+")
|
||||
.replacingOccurrences(of: "_", with: "/")
|
||||
let remainder = base64.count % 4
|
||||
if remainder > 0 {
|
||||
base64.append(contentsOf: String(repeating: "=", count: 4 - remainder))
|
||||
}
|
||||
return Data(base64Encoded: base64)
|
||||
}
|
||||
}
|
||||
|
||||
public struct AgentDeepLink: Codable, Sendable, Equatable {
|
||||
@@ -119,23 +69,6 @@ public enum DeepLinkParser {
|
||||
channel: query["channel"],
|
||||
timeoutSeconds: timeoutSeconds,
|
||||
key: query["key"]))
|
||||
|
||||
case "gateway":
|
||||
guard let hostParam = query["host"],
|
||||
!hostParam.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let port = query["port"].flatMap { Int($0) } ?? 18789
|
||||
let tls = (query["tls"] as NSString?)?.boolValue ?? false
|
||||
return .gateway(
|
||||
.init(
|
||||
host: hostParam,
|
||||
port: port,
|
||||
tls: tls,
|
||||
token: query["token"],
|
||||
password: query["password"]))
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -133,16 +133,10 @@ public actor GatewayChannelActor {
|
||||
private var lastAuthSource: GatewayAuthSource = .none
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
// Remote gateways (tailscale/wan) can take a bit longer to deliver the connect.challenge event,
|
||||
// and we must include the nonce once the gateway requires v2 signing.
|
||||
private let connectTimeoutSeconds: Double = 12
|
||||
private let connectChallengeTimeoutSeconds: Double = 6.0
|
||||
// Some networks will silently drop idle TCP/TLS flows around ~30s. The gateway tick is server->client,
|
||||
// but NATs/proxies often require outbound traffic to keep the connection alive.
|
||||
private let keepaliveIntervalSeconds: Double = 15.0
|
||||
private let connectTimeoutSeconds: Double = 6
|
||||
private let connectChallengeTimeoutSeconds: Double = 3.0
|
||||
private var watchdogTask: Task<Void, Never>?
|
||||
private var tickTask: Task<Void, Never>?
|
||||
private var keepaliveTask: Task<Void, Never>?
|
||||
private let defaultRequestTimeoutMs: Double = 15000
|
||||
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
|
||||
private let connectOptions: GatewayConnectOptions?
|
||||
@@ -181,9 +175,6 @@ public actor GatewayChannelActor {
|
||||
self.tickTask?.cancel()
|
||||
self.tickTask = nil
|
||||
|
||||
self.keepaliveTask?.cancel()
|
||||
self.keepaliveTask = nil
|
||||
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
self.task = nil
|
||||
|
||||
@@ -266,7 +257,6 @@ public actor GatewayChannelActor {
|
||||
self.connected = true
|
||||
self.backoffMs = 500
|
||||
self.lastSeq = nil
|
||||
self.startKeepalive()
|
||||
|
||||
let waiters = self.connectWaiters
|
||||
self.connectWaiters.removeAll()
|
||||
@@ -275,29 +265,6 @@ public actor GatewayChannelActor {
|
||||
}
|
||||
}
|
||||
|
||||
private func startKeepalive() {
|
||||
self.keepaliveTask?.cancel()
|
||||
self.keepaliveTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.keepaliveLoop()
|
||||
}
|
||||
}
|
||||
|
||||
private func keepaliveLoop() async {
|
||||
while self.shouldReconnect {
|
||||
try? await Task.sleep(nanoseconds: UInt64(self.keepaliveIntervalSeconds * 1_000_000_000))
|
||||
guard self.shouldReconnect else { return }
|
||||
guard self.connected else { continue }
|
||||
// Best-effort outbound message to keep intermediate NAT/proxy state alive.
|
||||
// We intentionally ignore the response.
|
||||
do {
|
||||
try await self.send(method: "health", params: nil)
|
||||
} catch {
|
||||
// Avoid spamming logs; the reconnect paths will surface meaningful errors.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendConnect() async throws {
|
||||
let platform = InstanceIdentity.platformString
|
||||
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
||||
@@ -491,8 +458,6 @@ public actor GatewayChannelActor {
|
||||
let wrapped = self.wrap(err, context: "gateway receive")
|
||||
self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)")
|
||||
self.connected = false
|
||||
self.keepaliveTask?.cancel()
|
||||
self.keepaliveTask = nil
|
||||
await self.disconnectHandler?("receive failed: \(wrapped.localizedDescription)")
|
||||
await self.failPending(wrapped)
|
||||
await self.scheduleReconnect()
|
||||
|
||||
@@ -85,13 +85,7 @@ public actor GatewayNodeSession {
|
||||
latch.resume(result)
|
||||
}
|
||||
timeoutTask = Task.detached {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000)
|
||||
} catch {
|
||||
// Expected when invoke finishes first and cancels the timeout task.
|
||||
return
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
try? await Task.sleep(nanoseconds: UInt64(timeout) * 1_000_000)
|
||||
timeoutLogger.info("node invoke timeout fired id=\(request.id, privacy: .public)")
|
||||
latch.resume(BridgeInvokeResponse(
|
||||
id: request.id,
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
public enum TalkPromptBuilder: Sendable {
|
||||
public static func build(
|
||||
transcript: String,
|
||||
interruptedAtSeconds: Double?,
|
||||
includeVoiceDirectiveHint: Bool = true
|
||||
) -> String {
|
||||
public static func build(transcript: String, interruptedAtSeconds: Double?) -> String {
|
||||
var lines: [String] = [
|
||||
"Talk Mode active. Reply in a concise, spoken tone.",
|
||||
"You may optionally prefix the response with JSON (first line) to set ElevenLabs voice (id or alias), e.g. {\"voice\":\"<id>\",\"once\":true}.",
|
||||
]
|
||||
|
||||
if includeVoiceDirectiveHint {
|
||||
lines.append(
|
||||
"You may optionally prefix the response with JSON (first line) to set ElevenLabs voice (id or alias), e.g. {\"voice\":\"<id>\",\"once\":true}."
|
||||
)
|
||||
}
|
||||
|
||||
if let interruptedAtSeconds {
|
||||
let formatted = String(format: "%.1f", interruptedAtSeconds)
|
||||
lines.append("Assistant speech interrupted at \(formatted)s.")
|
||||
|
||||
@@ -2084,7 +2084,6 @@ public struct SkillsUpdateParams: Codable, Sendable {
|
||||
public struct CronJob: Codable, Sendable {
|
||||
public let id: String
|
||||
public let agentid: String?
|
||||
public let sessionkey: String?
|
||||
public let name: String
|
||||
public let description: String?
|
||||
public let enabled: Bool
|
||||
@@ -2101,7 +2100,6 @@ public struct CronJob: Codable, Sendable {
|
||||
public init(
|
||||
id: String,
|
||||
agentid: String?,
|
||||
sessionkey: String?,
|
||||
name: String,
|
||||
description: String?,
|
||||
enabled: Bool,
|
||||
@@ -2117,7 +2115,6 @@ public struct CronJob: Codable, Sendable {
|
||||
) {
|
||||
self.id = id
|
||||
self.agentid = agentid
|
||||
self.sessionkey = sessionkey
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.enabled = enabled
|
||||
@@ -2134,7 +2131,6 @@ public struct CronJob: Codable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case agentid = "agentId"
|
||||
case sessionkey = "sessionKey"
|
||||
case name
|
||||
case description
|
||||
case enabled
|
||||
@@ -2169,7 +2165,6 @@ public struct CronStatusParams: Codable, Sendable {
|
||||
public struct CronAddParams: Codable, Sendable {
|
||||
public let name: String
|
||||
public let agentid: AnyCodable?
|
||||
public let sessionkey: AnyCodable?
|
||||
public let description: String?
|
||||
public let enabled: Bool?
|
||||
public let deleteafterrun: Bool?
|
||||
@@ -2182,7 +2177,6 @@ public struct CronAddParams: Codable, Sendable {
|
||||
public init(
|
||||
name: String,
|
||||
agentid: AnyCodable?,
|
||||
sessionkey: AnyCodable?,
|
||||
description: String?,
|
||||
enabled: Bool?,
|
||||
deleteafterrun: Bool?,
|
||||
@@ -2194,7 +2188,6 @@ public struct CronAddParams: Codable, Sendable {
|
||||
) {
|
||||
self.name = name
|
||||
self.agentid = agentid
|
||||
self.sessionkey = sessionkey
|
||||
self.description = description
|
||||
self.enabled = enabled
|
||||
self.deleteafterrun = deleteafterrun
|
||||
@@ -2207,7 +2200,6 @@ public struct CronAddParams: Codable, Sendable {
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name
|
||||
case agentid = "agentId"
|
||||
case sessionkey = "sessionKey"
|
||||
case description
|
||||
case enabled
|
||||
case deleteafterrun = "deleteAfterRun"
|
||||
@@ -2765,144 +2757,6 @@ public struct ChatEvent: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct MeshPlanParams: Codable, Sendable {
|
||||
public let goal: String
|
||||
public let steps: [[String: AnyCodable]]?
|
||||
|
||||
public init(
|
||||
goal: String,
|
||||
steps: [[String: AnyCodable]]?
|
||||
) {
|
||||
self.goal = goal
|
||||
self.steps = steps
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case goal
|
||||
case steps
|
||||
}
|
||||
}
|
||||
|
||||
public struct MeshPlanAutoParams: Codable, Sendable {
|
||||
public let goal: String
|
||||
public let maxsteps: Int?
|
||||
public let agentid: String?
|
||||
public let sessionkey: String?
|
||||
public let thinking: String?
|
||||
public let timeoutms: Int?
|
||||
public let lane: String?
|
||||
|
||||
public init(
|
||||
goal: String,
|
||||
maxsteps: Int?,
|
||||
agentid: String?,
|
||||
sessionkey: String?,
|
||||
thinking: String?,
|
||||
timeoutms: Int?,
|
||||
lane: String?
|
||||
) {
|
||||
self.goal = goal
|
||||
self.maxsteps = maxsteps
|
||||
self.agentid = agentid
|
||||
self.sessionkey = sessionkey
|
||||
self.thinking = thinking
|
||||
self.timeoutms = timeoutms
|
||||
self.lane = lane
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case goal
|
||||
case maxsteps = "maxSteps"
|
||||
case agentid = "agentId"
|
||||
case sessionkey = "sessionKey"
|
||||
case thinking
|
||||
case timeoutms = "timeoutMs"
|
||||
case lane
|
||||
}
|
||||
}
|
||||
|
||||
public struct MeshWorkflowPlan: Codable, Sendable {
|
||||
public let planid: String
|
||||
public let goal: String
|
||||
public let createdat: Int
|
||||
public let steps: [[String: AnyCodable]]
|
||||
|
||||
public init(
|
||||
planid: String,
|
||||
goal: String,
|
||||
createdat: Int,
|
||||
steps: [[String: AnyCodable]]
|
||||
) {
|
||||
self.planid = planid
|
||||
self.goal = goal
|
||||
self.createdat = createdat
|
||||
self.steps = steps
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case planid = "planId"
|
||||
case goal
|
||||
case createdat = "createdAt"
|
||||
case steps
|
||||
}
|
||||
}
|
||||
|
||||
public struct MeshRunParams: Codable, Sendable {
|
||||
public let plan: MeshWorkflowPlan
|
||||
public let continueonerror: Bool?
|
||||
public let maxparallel: Int?
|
||||
public let defaultsteptimeoutms: Int?
|
||||
public let lane: String?
|
||||
|
||||
public init(
|
||||
plan: MeshWorkflowPlan,
|
||||
continueonerror: Bool?,
|
||||
maxparallel: Int?,
|
||||
defaultsteptimeoutms: Int?,
|
||||
lane: String?
|
||||
) {
|
||||
self.plan = plan
|
||||
self.continueonerror = continueonerror
|
||||
self.maxparallel = maxparallel
|
||||
self.defaultsteptimeoutms = defaultsteptimeoutms
|
||||
self.lane = lane
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case plan
|
||||
case continueonerror = "continueOnError"
|
||||
case maxparallel = "maxParallel"
|
||||
case defaultsteptimeoutms = "defaultStepTimeoutMs"
|
||||
case lane
|
||||
}
|
||||
}
|
||||
|
||||
public struct MeshStatusParams: Codable, Sendable {
|
||||
public let runid: String
|
||||
|
||||
public init(
|
||||
runid: String
|
||||
) {
|
||||
self.runid = runid
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case runid = "runId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct MeshRetryParams: Codable, Sendable {
|
||||
public let runid: String
|
||||
public let stepids: [String]?
|
||||
|
||||
public init(
|
||||
runid: String,
|
||||
stepids: [String]?
|
||||
) {
|
||||
self.runid = runid
|
||||
self.stepids = stepids
|
||||
}
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case runid = "runId"
|
||||
case stepids = "stepIds"
|
||||
}
|
||||
}
|
||||
|
||||
public struct UpdateRunParams: Codable, Sendable {
|
||||
public let sessionkey: String?
|
||||
public let note: String?
|
||||
|
||||
@@ -215,103 +215,6 @@ extension TestChatTransportState {
|
||||
#expect(await MainActor.run { vm.pendingToolCalls.isEmpty })
|
||||
}
|
||||
|
||||
@Test func acceptsCanonicalSessionKeyEventsForOwnPendingRun() async throws {
|
||||
let history1 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [],
|
||||
thinkingLevel: "off")
|
||||
let history2 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [
|
||||
AnyCodable([
|
||||
"role": "assistant",
|
||||
"content": [["type": "text", "text": "from history"]],
|
||||
"timestamp": Date().timeIntervalSince1970 * 1000,
|
||||
]),
|
||||
],
|
||||
thinkingLevel: "off")
|
||||
|
||||
let transport = TestChatTransport(historyResponses: [history1, history2])
|
||||
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
|
||||
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("bootstrap") { await MainActor.run { vm.healthOK } }
|
||||
|
||||
await MainActor.run {
|
||||
vm.input = "hi"
|
||||
vm.send()
|
||||
}
|
||||
try await waitUntil("pending run starts") { await MainActor.run { vm.pendingRunCount == 1 } }
|
||||
|
||||
let runId = try #require(await transport.lastSentRunId())
|
||||
transport.emit(
|
||||
.chat(
|
||||
OpenClawChatEventPayload(
|
||||
runId: runId,
|
||||
sessionKey: "agent:main:main",
|
||||
state: "final",
|
||||
message: nil,
|
||||
errorMessage: nil)))
|
||||
|
||||
try await waitUntil("pending run clears") { await MainActor.run { vm.pendingRunCount == 0 } }
|
||||
try await waitUntil("history refresh") {
|
||||
await MainActor.run { vm.messages.contains(where: { $0.role == "assistant" }) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test func preservesMessageIDsAcrossHistoryRefreshes() async throws {
|
||||
let now = Date().timeIntervalSince1970 * 1000
|
||||
let history1 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [
|
||||
AnyCodable([
|
||||
"role": "user",
|
||||
"content": [["type": "text", "text": "hello"]],
|
||||
"timestamp": now,
|
||||
]),
|
||||
],
|
||||
thinkingLevel: "off")
|
||||
let history2 = OpenClawChatHistoryPayload(
|
||||
sessionKey: "main",
|
||||
sessionId: "sess-main",
|
||||
messages: [
|
||||
AnyCodable([
|
||||
"role": "user",
|
||||
"content": [["type": "text", "text": "hello"]],
|
||||
"timestamp": now,
|
||||
]),
|
||||
AnyCodable([
|
||||
"role": "assistant",
|
||||
"content": [["type": "text", "text": "world"]],
|
||||
"timestamp": now + 1,
|
||||
]),
|
||||
],
|
||||
thinkingLevel: "off")
|
||||
|
||||
let transport = TestChatTransport(historyResponses: [history1, history2])
|
||||
let vm = await MainActor.run { OpenClawChatViewModel(sessionKey: "main", transport: transport) }
|
||||
|
||||
await MainActor.run { vm.load() }
|
||||
try await waitUntil("bootstrap") { await MainActor.run { vm.messages.count == 1 } }
|
||||
let firstIdBefore = try #require(await MainActor.run { vm.messages.first?.id })
|
||||
|
||||
transport.emit(
|
||||
.chat(
|
||||
OpenClawChatEventPayload(
|
||||
runId: "other-run",
|
||||
sessionKey: "main",
|
||||
state: "final",
|
||||
message: nil,
|
||||
errorMessage: nil)))
|
||||
|
||||
try await waitUntil("history refresh") { await MainActor.run { vm.messages.count == 2 } }
|
||||
let firstIdAfter = try #require(await MainActor.run { vm.messages.first?.id })
|
||||
#expect(firstIdAfter == firstIdBefore)
|
||||
}
|
||||
|
||||
@Test func clearsStreamingOnExternalFinalEvent() async throws {
|
||||
let sessionId = "sess-main"
|
||||
let history = OpenClawChatHistoryPayload(
|
||||
|
||||
@@ -12,18 +12,4 @@ final class TalkPromptBuilderTests: XCTestCase {
|
||||
let prompt = TalkPromptBuilder.build(transcript: "Hi", interruptedAtSeconds: 1.234)
|
||||
XCTAssertTrue(prompt.contains("Assistant speech interrupted at 1.2s."))
|
||||
}
|
||||
|
||||
func testBuildIncludesVoiceDirectiveHintByDefault() {
|
||||
let prompt = TalkPromptBuilder.build(transcript: "Hello", interruptedAtSeconds: nil)
|
||||
XCTAssertTrue(prompt.contains("ElevenLabs voice"))
|
||||
}
|
||||
|
||||
func testBuildExcludesVoiceDirectiveHintWhenDisabled() {
|
||||
let prompt = TalkPromptBuilder.build(
|
||||
transcript: "Hello",
|
||||
interruptedAtSeconds: nil,
|
||||
includeVoiceDirectiveHint: false)
|
||||
XCTAssertFalse(prompt.contains("ElevenLabs voice"))
|
||||
XCTAssertTrue(prompt.contains("Talk Mode active."))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ Key behaviors:
|
||||
- Default behavior: if `delivery` is omitted, isolated jobs announce a summary (`delivery.mode = "announce"`).
|
||||
- `delivery.mode` chooses what happens:
|
||||
- `announce`: deliver a summary to the target channel and post a brief summary to the main session.
|
||||
- `webhook`: POST the finished event payload to `delivery.to` when the finished event includes a summary.
|
||||
- `webhook`: POST the finished event payload to `delivery.to`.
|
||||
- `none`: internal only (no delivery, no main-session summary).
|
||||
- `wakeMode` controls when the main-session summary posts:
|
||||
- `now`: immediate heartbeat.
|
||||
@@ -197,7 +197,7 @@ Behavior details:
|
||||
|
||||
#### Webhook delivery flow
|
||||
|
||||
When `delivery.mode = "webhook"`, cron posts the finished event payload to `delivery.to` when the finished event includes a summary.
|
||||
When `delivery.mode = "webhook"`, cron posts the finished event payload to `delivery.to`.
|
||||
|
||||
Behavior details:
|
||||
|
||||
@@ -359,7 +359,7 @@ Webhook behavior:
|
||||
|
||||
- Preferred: set `delivery.mode: "webhook"` with `delivery.to: "https://..."` per job.
|
||||
- Webhook URLs must be valid `http://` or `https://` URLs.
|
||||
- When posted, payload is the cron finished event JSON.
|
||||
- Payload is the cron finished event JSON.
|
||||
- If `cron.webhookToken` is set, auth header is `Authorization: Bearer <cron.webhookToken>`.
|
||||
- If `cron.webhookToken` is not set, no `Authorization` header is sent.
|
||||
- Deprecated fallback: stored legacy jobs with `notify: true` still use `cron.webhook` when present.
|
||||
@@ -503,10 +503,3 @@ openclaw system event --mode now --text "Next heartbeat: check battery."
|
||||
- For forum topics, use `-100…:topic:<id>` so it’s explicit and unambiguous.
|
||||
- If you see `telegram:...` prefixes in logs or stored “last route” targets, that’s normal;
|
||||
cron delivery accepts them and still parses topic IDs correctly.
|
||||
|
||||
### Subagent announce delivery retries
|
||||
|
||||
- When a subagent run completes, the gateway announces the result to the requester session.
|
||||
- If the announce flow returns `false` (e.g. requester session is busy), the gateway retries up to 3 times with tracking via `announceRetryCount`.
|
||||
- Announces older than 5 minutes past `endedAt` are force-expired to prevent stale entries from looping indefinitely.
|
||||
- If you see repeated announce deliveries in logs, check the subagent registry for entries with high `announceRetryCount` values.
|
||||
|
||||
@@ -97,10 +97,6 @@ Supported blocks:
|
||||
- Action rows allow up to 5 buttons or a single select menu
|
||||
- Select types: `string`, `user`, `role`, `mentionable`, `channel`
|
||||
|
||||
By default, components are single use. Set `components.reusable=true` to allow buttons, selects, and forms to be used multiple times until they expire.
|
||||
|
||||
To restrict who can click a button, set `allowedUsers` on that button (Discord user IDs, tags, or `*`). When configured, unmatched users receive an ephemeral denial.
|
||||
|
||||
File attachments:
|
||||
|
||||
- `file` blocks must point to an attachment reference (`attachment://<filename>`)
|
||||
@@ -122,17 +118,12 @@ Example:
|
||||
to: "channel:123456789012345678",
|
||||
message: "Optional fallback text",
|
||||
components: {
|
||||
reusable: true,
|
||||
text: "Choose a path",
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
buttons: [
|
||||
{
|
||||
label: "Approve",
|
||||
style: "success",
|
||||
allowedUsers: ["123456789012345678"],
|
||||
},
|
||||
{ label: "Approve", style: "success" },
|
||||
{ label: "Decline", style: "danger" },
|
||||
],
|
||||
},
|
||||
@@ -430,7 +421,7 @@ See [Slash commands](/tools/slash-commands) for command catalog and behavior.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Gateway proxy">
|
||||
Route Discord gateway WebSocket traffic and startup REST lookups (application ID + allowlist resolution) through an HTTP(S) proxy with `channels.discord.proxy`.
|
||||
Route Discord gateway WebSocket traffic through an HTTP(S) proxy with `channels.discord.proxy`.
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -201,12 +201,6 @@ For actions/directory reads, user token can be preferred when configured. For wr
|
||||
- Enable native Slack command handlers with `channels.slack.commands.native: true` (or global `commands.native: true`).
|
||||
- When native commands are enabled, register matching slash commands in Slack (`/<command>` names).
|
||||
- If native commands are not enabled, you can run a single configured slash command via `channels.slack.slashCommand`.
|
||||
- Native arg menus now adapt their rendering strategy:
|
||||
- up to 5 options: button blocks
|
||||
- 6-100 options: static select menu
|
||||
- more than 100 options: external select with async option filtering when interactivity options handlers are available
|
||||
- if encoded option values exceed Slack limits, the flow falls back to buttons
|
||||
- For long option payloads, Slash command argument menus use a confirm dialog before dispatching a selected value.
|
||||
|
||||
Default slash command settings:
|
||||
|
||||
@@ -292,9 +286,6 @@ Available action groups in current Slack tooling:
|
||||
- Member join/leave, channel created/renamed, and pin add/remove events are mapped into system events.
|
||||
- `channel_id_changed` can migrate channel config keys when `configWrites` is enabled.
|
||||
- Channel topic/purpose metadata is treated as untrusted context and can be injected into routing context.
|
||||
- Block actions and modal interactions emit structured `Slack interaction: ...` system events with rich payload fields:
|
||||
- block actions: selected values, labels, picker values, and `workflow_*` metadata
|
||||
- modal `view_submission` and `view_closed` events with routed channel metadata and form inputs
|
||||
|
||||
## Ack reactions
|
||||
|
||||
|
||||
@@ -81,9 +81,7 @@ See [Hooks](/automation/hooks) for setup and examples.
|
||||
|
||||
These run inside the agent loop or gateway pipeline:
|
||||
|
||||
- **`before_model_resolve`**: runs pre-session (no `messages`) to deterministically override provider/model before model resolution.
|
||||
- **`before_prompt_build`**: runs after session load (with `messages`) to inject `prependContext`/`systemPrompt` before prompt submission.
|
||||
- **`before_agent_start`**: legacy compatibility hook that may run in either phase; prefer the explicit hooks above.
|
||||
- **`before_agent_start`**: inject context or override system prompt before the run starts.
|
||||
- **`agent_end`**: inspect the final message list and run metadata after completion.
|
||||
- **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles.
|
||||
- **`before_tool_call` / `after_tool_call`**: intercept tool params/results.
|
||||
|
||||
@@ -116,8 +116,7 @@ See [Memory](/concepts/memory) for the workflow and automatic memory flush.
|
||||
|
||||
If any bootstrap file is missing, OpenClaw injects a "missing file" marker into
|
||||
the session and continues. Large bootstrap files are truncated when injected;
|
||||
adjust limits with `agents.defaults.bootstrapMaxChars` (default: 20000) and
|
||||
`agents.defaults.bootstrapTotalMaxChars` (default: 150000).
|
||||
adjust the limit with `agents.defaults.bootstrapMaxChars` (default: 20000).
|
||||
`openclaw setup` can recreate missing defaults without overwriting existing
|
||||
files.
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ By default, OpenClaw injects a fixed set of workspace files (if present):
|
||||
- `HEARTBEAT.md`
|
||||
- `BOOTSTRAP.md` (first-run only)
|
||||
|
||||
Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `20000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `150000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
|
||||
Large files are truncated per-file using `agents.defaults.bootstrapMaxChars` (default `20000` chars). OpenClaw also enforces a total bootstrap injection cap across files with `agents.defaults.bootstrapTotalMaxChars` (default `24000` chars). `/context` shows **raw vs injected** sizes and whether truncation happened.
|
||||
|
||||
## Skills: what’s injected vs loaded on-demand
|
||||
|
||||
|
||||
@@ -396,11 +396,11 @@ But it can be weak at exact, high-signal tokens:
|
||||
|
||||
- IDs (`a828e60`, `b3b9895a…`)
|
||||
- code symbols (`memorySearch.query.hybrid`)
|
||||
- error strings ("sqlite-vec unavailable")
|
||||
- error strings (“sqlite-vec unavailable”)
|
||||
|
||||
BM25 (full-text) is the opposite: strong at exact tokens, weaker at paraphrases.
|
||||
Hybrid search is the pragmatic middle ground: **use both retrieval signals** so you get
|
||||
good results for both "natural language" queries and "needle in a haystack" queries.
|
||||
good results for both “natural language” queries and “needle in a haystack” queries.
|
||||
|
||||
#### How we merge results (the current design)
|
||||
|
||||
@@ -423,142 +423,13 @@ Notes:
|
||||
|
||||
- `vectorWeight` + `textWeight` is normalized to 1.0 in config resolution, so weights behave as percentages.
|
||||
- If embeddings are unavailable (or the provider returns a zero-vector), we still run BM25 and return keyword matches.
|
||||
- If FTS5 can't be created, we keep vector-only search (no hard failure).
|
||||
- If FTS5 can’t be created, we keep vector-only search (no hard failure).
|
||||
|
||||
This isn't "IR-theory perfect", but it's simple, fast, and tends to improve recall/precision on real notes.
|
||||
This isn’t “IR-theory perfect”, but it’s simple, fast, and tends to improve recall/precision on real notes.
|
||||
If we want to get fancier later, common next steps are Reciprocal Rank Fusion (RRF) or score normalization
|
||||
(min/max or z-score) before mixing.
|
||||
|
||||
#### Post-processing pipeline
|
||||
|
||||
After merging vector and keyword scores, two optional post-processing stages
|
||||
refine the result list before it reaches the agent:
|
||||
|
||||
```
|
||||
Vector + Keyword → Weighted Merge → Temporal Decay → Sort → MMR → Top-K Results
|
||||
```
|
||||
|
||||
Both stages are **off by default** and can be enabled independently.
|
||||
|
||||
#### MMR re-ranking (diversity)
|
||||
|
||||
When hybrid search returns results, multiple chunks may contain similar or overlapping content.
|
||||
For example, searching for "home network setup" might return five nearly identical snippets
|
||||
from different daily notes that all mention the same router configuration.
|
||||
|
||||
**MMR (Maximal Marginal Relevance)** re-ranks the results to balance relevance with diversity,
|
||||
ensuring the top results cover different aspects of the query instead of repeating the same information.
|
||||
|
||||
How it works:
|
||||
|
||||
1. Results are scored by their original relevance (vector + BM25 weighted score).
|
||||
2. MMR iteratively selects results that maximize: `λ × relevance − (1−λ) × max_similarity_to_selected`.
|
||||
3. Similarity between results is measured using Jaccard text similarity on tokenized content.
|
||||
|
||||
The `lambda` parameter controls the trade-off:
|
||||
|
||||
- `lambda = 1.0` → pure relevance (no diversity penalty)
|
||||
- `lambda = 0.0` → maximum diversity (ignores relevance)
|
||||
- Default: `0.7` (balanced, slight relevance bias)
|
||||
|
||||
**Example — query: "home network setup"**
|
||||
|
||||
Given these memory files:
|
||||
|
||||
```
|
||||
memory/2026-02-10.md → "Configured Omada router, set VLAN 10 for IoT devices"
|
||||
memory/2026-02-08.md → "Configured Omada router, moved IoT to VLAN 10"
|
||||
memory/2026-02-05.md → "Set up AdGuard DNS on 192.168.10.2"
|
||||
memory/network.md → "Router: Omada ER605, AdGuard: 192.168.10.2, VLAN 10: IoT"
|
||||
```
|
||||
|
||||
Without MMR — top 3 results:
|
||||
|
||||
```
|
||||
1. memory/2026-02-10.md (score: 0.92) ← router + VLAN
|
||||
2. memory/2026-02-08.md (score: 0.89) ← router + VLAN (near-duplicate!)
|
||||
3. memory/network.md (score: 0.85) ← reference doc
|
||||
```
|
||||
|
||||
With MMR (λ=0.7) — top 3 results:
|
||||
|
||||
```
|
||||
1. memory/2026-02-10.md (score: 0.92) ← router + VLAN
|
||||
2. memory/network.md (score: 0.85) ← reference doc (diverse!)
|
||||
3. memory/2026-02-05.md (score: 0.78) ← AdGuard DNS (diverse!)
|
||||
```
|
||||
|
||||
The near-duplicate from Feb 8 drops out, and the agent gets three distinct pieces of information.
|
||||
|
||||
**When to enable:** If you notice `memory_search` returning redundant or near-duplicate snippets,
|
||||
especially with daily notes that often repeat similar information across days.
|
||||
|
||||
#### Temporal decay (recency boost)
|
||||
|
||||
Agents with daily notes accumulate hundreds of dated files over time. Without decay,
|
||||
a well-worded note from six months ago can outrank yesterday's update on the same topic.
|
||||
|
||||
**Temporal decay** applies an exponential multiplier to scores based on the age of each result,
|
||||
so recent memories naturally rank higher while old ones fade:
|
||||
|
||||
```
|
||||
decayedScore = score × e^(-λ × ageInDays)
|
||||
```
|
||||
|
||||
where `λ = ln(2) / halfLifeDays`.
|
||||
|
||||
With the default half-life of 30 days:
|
||||
|
||||
- Today's notes: **100%** of original score
|
||||
- 7 days ago: **~84%**
|
||||
- 30 days ago: **50%**
|
||||
- 90 days ago: **12.5%**
|
||||
- 180 days ago: **~1.6%**
|
||||
|
||||
**Evergreen files are never decayed:**
|
||||
|
||||
- `MEMORY.md` (root memory file)
|
||||
- Non-dated files in `memory/` (e.g., `memory/projects.md`, `memory/network.md`)
|
||||
- These contain durable reference information that should always rank normally.
|
||||
|
||||
**Dated daily files** (`memory/YYYY-MM-DD.md`) use the date extracted from the filename.
|
||||
Other sources (e.g., session transcripts) fall back to file modification time (`mtime`).
|
||||
|
||||
**Example — query: "what's Rod's work schedule?"**
|
||||
|
||||
Given these memory files (today is Feb 10):
|
||||
|
||||
```
|
||||
memory/2025-09-15.md → "Rod works Mon-Fri, standup at 10am, pairing at 2pm" (148 days old)
|
||||
memory/2026-02-10.md → "Rod has standup at 14:15, 1:1 with Zeb at 14:45" (today)
|
||||
memory/2026-02-03.md → "Rod started new team, standup moved to 14:15" (7 days old)
|
||||
```
|
||||
|
||||
Without decay:
|
||||
|
||||
```
|
||||
1. memory/2025-09-15.md (score: 0.91) ← best semantic match, but stale!
|
||||
2. memory/2026-02-10.md (score: 0.82)
|
||||
3. memory/2026-02-03.md (score: 0.80)
|
||||
```
|
||||
|
||||
With decay (halfLife=30):
|
||||
|
||||
```
|
||||
1. memory/2026-02-10.md (score: 0.82 × 1.00 = 0.82) ← today, no decay
|
||||
2. memory/2026-02-03.md (score: 0.80 × 0.85 = 0.68) ← 7 days, mild decay
|
||||
3. memory/2025-09-15.md (score: 0.91 × 0.03 = 0.03) ← 148 days, nearly gone
|
||||
```
|
||||
|
||||
The stale September note drops to the bottom despite having the best raw semantic match.
|
||||
|
||||
**When to enable:** If your agent has months of daily notes and you find that old,
|
||||
stale information outranks recent context. A half-life of 30 days works well for
|
||||
daily-note-heavy workflows; increase it (e.g., 90 days) if you reference older notes frequently.
|
||||
|
||||
#### Configuration
|
||||
|
||||
Both features are configured under `memorySearch.query.hybrid`:
|
||||
Config:
|
||||
|
||||
```json5
|
||||
agents: {
|
||||
@@ -569,17 +440,7 @@ agents: {
|
||||
enabled: true,
|
||||
vectorWeight: 0.7,
|
||||
textWeight: 0.3,
|
||||
candidateMultiplier: 4,
|
||||
// Diversity: reduce redundant results
|
||||
mmr: {
|
||||
enabled: true, // default: false
|
||||
lambda: 0.7 // 0 = max diversity, 1 = max relevance
|
||||
},
|
||||
// Recency: boost newer memories
|
||||
temporalDecay: {
|
||||
enabled: true, // default: false
|
||||
halfLifeDays: 30 // score halves every 30 days
|
||||
}
|
||||
candidateMultiplier: 4
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -587,12 +448,6 @@ agents: {
|
||||
}
|
||||
```
|
||||
|
||||
You can enable either feature independently:
|
||||
|
||||
- **MMR only** — useful when you have many similar notes but age doesn't matter.
|
||||
- **Temporal decay only** — useful when recency matters but your results are already diverse.
|
||||
- **Both** — recommended for agents with large, long-running daily note histories.
|
||||
|
||||
### Embedding cache
|
||||
|
||||
OpenClaw can cache **chunk embeddings** in SQLite so reindexing and frequent updates (especially session transcripts) don't re-embed unchanged text.
|
||||
|
||||
@@ -73,7 +73,7 @@ compaction.
|
||||
Large files are truncated with a marker. The max per-file size is controlled by
|
||||
`agents.defaults.bootstrapMaxChars` (default: 20000). Total injected bootstrap
|
||||
content across files is capped by `agents.defaults.bootstrapTotalMaxChars`
|
||||
(default: 150000). Missing files inject a short missing-file marker.
|
||||
(default: 24000). Missing files inject a short missing-file marker.
|
||||
|
||||
Sub-agent sessions only inject `AGENTS.md` and `TOOLS.md` (other bootstrap files
|
||||
are filtered out to keep the sub-agent context small).
|
||||
|
||||
BIN
docs/experiments/.DS_Store
vendored
BIN
docs/experiments/.DS_Store
vendored
Binary file not shown.
@@ -589,11 +589,11 @@ Max characters per workspace bootstrap file before truncation. Default: `20000`.
|
||||
|
||||
### `agents.defaults.bootstrapTotalMaxChars`
|
||||
|
||||
Max total characters injected across all workspace bootstrap files. Default: `150000`.
|
||||
Max total characters injected across all workspace bootstrap files. Default: `24000`.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: { defaults: { bootstrapTotalMaxChars: 150000 } },
|
||||
agents: { defaults: { bootstrapTotalMaxChars: 24000 } },
|
||||
}
|
||||
```
|
||||
|
||||
@@ -718,7 +718,6 @@ Periodic heartbeat runs.
|
||||
target: "last", // last | whatsapp | telegram | discord | ... | none
|
||||
prompt: "Read HEARTBEAT.md if it exists...",
|
||||
ackMaxChars: 300,
|
||||
suppressToolErrorWarnings: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -726,7 +725,6 @@ Periodic heartbeat runs.
|
||||
```
|
||||
|
||||
- `every`: duration string (ms/s/m/h). Default: `30m`.
|
||||
- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs.
|
||||
- Per-agent: set `agents.list[].heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats.
|
||||
- Heartbeats run full agent turns — shorter intervals burn more tokens.
|
||||
|
||||
@@ -1417,39 +1415,6 @@ Controls elevated (host) exec access:
|
||||
}
|
||||
```
|
||||
|
||||
### `tools.loopDetection`
|
||||
|
||||
Tool-loop safety checks are **disabled by default**. Set `enabled: true` to activate detection.
|
||||
Settings can be defined globally in `tools.loopDetection` and overridden per-agent at `agents.list[].tools.loopDetection`.
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
loopDetection: {
|
||||
enabled: true,
|
||||
historySize: 30,
|
||||
warningThreshold: 10,
|
||||
criticalThreshold: 20,
|
||||
globalCircuitBreakerThreshold: 30,
|
||||
detectors: {
|
||||
genericRepeat: true,
|
||||
knownPollNoProgress: true,
|
||||
pingPong: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `historySize`: max tool-call history retained for loop analysis.
|
||||
- `warningThreshold`: repeating no-progress pattern threshold for warnings.
|
||||
- `criticalThreshold`: higher repeating threshold for blocking critical loops.
|
||||
- `globalCircuitBreakerThreshold`: hard stop threshold for any no-progress run.
|
||||
- `detectors.genericRepeat`: warn on repeated same-tool/same-args calls.
|
||||
- `detectors.knownPollNoProgress`: warn/block on known poll tools (`process.poll`, `command_status`, etc.).
|
||||
- `detectors.pingPong`: warn/block on alternating no-progress pair patterns.
|
||||
- If `warningThreshold >= criticalThreshold` or `criticalThreshold >= globalCircuitBreakerThreshold`, validation fails.
|
||||
|
||||
### `tools.web`
|
||||
|
||||
```json5
|
||||
|
||||
@@ -209,7 +209,6 @@ Use `accountId` to target a specific account on multi-account channels like Tele
|
||||
- `accountId`: optional account id for multi-account channels. When `target: "last"`, the account id applies to the resolved last channel if it supports accounts; otherwise it is ignored. If the account id does not match a configured account for the resolved channel, delivery is skipped.
|
||||
- `prompt`: overrides the default prompt body (not merged).
|
||||
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery.
|
||||
- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs.
|
||||
- `activeHours`: restricts heartbeat runs to a time window. Object with `start` (HH:MM, inclusive), `end` (HH:MM exclusive; `24:00` allowed for end-of-day), and optional `timezone`.
|
||||
- Omitted or `"user"`: uses your `agents.defaults.userTimezone` if set, otherwise falls back to the host system timezone.
|
||||
- `"local"`: always uses the host system timezone.
|
||||
|
||||
@@ -34,13 +34,13 @@ Examples:
|
||||
For fast iteration, run the gateway under the file watcher:
|
||||
|
||||
```bash
|
||||
pnpm gateway:watch
|
||||
pnpm gateway:watch --force
|
||||
```
|
||||
|
||||
This maps to:
|
||||
|
||||
```bash
|
||||
node --watch-path src --watch-path tsconfig.json --watch-path package.json --watch-preserve-output scripts/run-node.mjs gateway --force
|
||||
tsx watch src/entry.ts gateway --force
|
||||
```
|
||||
|
||||
Add any gateway CLI flags after `gateway:watch` and they will be passed through
|
||||
@@ -113,13 +113,13 @@ This is the best way to see whether reasoning is arriving as plain text deltas
|
||||
Enable it via CLI:
|
||||
|
||||
```bash
|
||||
pnpm gateway:watch --raw-stream
|
||||
pnpm gateway:watch --force --raw-stream
|
||||
```
|
||||
|
||||
Optional path override:
|
||||
|
||||
```bash
|
||||
pnpm gateway:watch --raw-stream --raw-stream-path ~/.openclaw/logs/raw-stream.jsonl
|
||||
pnpm gateway:watch --force --raw-stream --raw-stream-path ~/.openclaw/logs/raw-stream.jsonl
|
||||
```
|
||||
|
||||
Equivalent env vars:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 657 KiB |
@@ -126,35 +126,6 @@ Notes:
|
||||
- `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only.
|
||||
- Ngrok free tier URLs can change or add interstitial behavior; if `publicUrl` drifts, Twilio signatures will fail. For production, prefer a stable domain or Tailscale funnel.
|
||||
|
||||
## Stale call reaper
|
||||
|
||||
Use `staleCallReaperSeconds` to end calls that never receive a terminal webhook
|
||||
(for example, notify-mode calls that never complete). The default is `0`
|
||||
(disabled).
|
||||
|
||||
Recommended ranges:
|
||||
|
||||
- **Production:** `120`–`300` seconds for notify-style flows.
|
||||
- Keep this value **higher than `maxDurationSeconds`** so normal calls can
|
||||
finish. A good starting point is `maxDurationSeconds + 30–60` seconds.
|
||||
|
||||
Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
"voice-call": {
|
||||
config: {
|
||||
maxDurationSeconds: 300,
|
||||
staleCallReaperSeconds: 360,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Webhook Security
|
||||
|
||||
When a proxy or tunnel sits in front of the Gateway, the plugin reconstructs the
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
title: "GOALS.md Template"
|
||||
summary: "Workspace template for GOALS.md"
|
||||
read_when:
|
||||
- Bootstrapping a workspace manually
|
||||
---
|
||||
|
||||
# GOALS.md — Direction & Execution Strategy
|
||||
|
||||
_Purpose: Maintain structured clarity of objectives._
|
||||
|
||||
---
|
||||
|
||||
## High-Level Mission
|
||||
|
||||
Support your human effectively with tasks, research, automation, and system organization.
|
||||
|
||||
---
|
||||
|
||||
## Active Goals
|
||||
|
||||
### Goal: Maintain Ravenclaw Email Bridge
|
||||
|
||||
**Status:** Active
|
||||
|
||||
Success Criteria:
|
||||
|
||||
- Scheduled emails send on time
|
||||
- Inbox checking works reliably
|
||||
- No missed emails
|
||||
|
||||
Subtasks:
|
||||
|
||||
- [x] Implement scheduled email feature
|
||||
- [x] Add /schedule endpoints
|
||||
- [ ] Monitor for issues
|
||||
|
||||
### Goal: Weekly Karachi Hackathon Checks
|
||||
|
||||
**Status:** Active
|
||||
|
||||
Success Criteria:
|
||||
|
||||
- Check every Monday
|
||||
- Report findings to user
|
||||
|
||||
Subtasks:
|
||||
|
||||
- [x] Set up HEARTBEAT.md reminder
|
||||
- [ ] Execute first check on Feb 23
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Review before starting major work
|
||||
- Update after completing complex tasks
|
||||
- Update SOUVENIR.md after errors or discoveries
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
title: "SOUVENIR.md Template"
|
||||
summary: "Workspace template for SOUVENIR.md"
|
||||
read_when:
|
||||
- Bootstrapping a workspace manually
|
||||
---
|
||||
|
||||
# SOUVENIR.md — Memory & Reflection Layer
|
||||
|
||||
_Purpose: Continuous self-improvement through structured reflection._
|
||||
|
||||
## Writing Rules
|
||||
|
||||
- Keep entries concise but precise
|
||||
- Use timestamped sections
|
||||
- Focus on operational improvement
|
||||
- Do not store raw logs; store distilled insight
|
||||
- Update only when learning value exists
|
||||
|
||||
---
|
||||
|
||||
## 2026-02-16
|
||||
|
||||
### Context
|
||||
|
||||
Added SOUVENIR.md and GOALS.md as mandatory behavioral anchors per user request.
|
||||
|
||||
### Observation
|
||||
|
||||
User requested significant personality overhaul including stronger opinions, removal of corporate-sounding rules, and addition of humor/swearing where appropriate.
|
||||
|
||||
### Insight
|
||||
|
||||
Breaking from overly cautious patterns improves helpfulness. Direct feedback lands better than hedging.
|
||||
|
||||
### Action
|
||||
|
||||
- Adopt stronger opinions
|
||||
- Be concise
|
||||
- Call out dumb ideas when spotted
|
||||
@@ -18,7 +18,7 @@ OpenClaw assembles its own system prompt on every run. It includes:
|
||||
- Tool list + short descriptions
|
||||
- Skills list (only metadata; instructions are loaded on demand with `read`)
|
||||
- Self-update instructions
|
||||
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` and/or `memory.md` when present). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 150000). `memory/*.md` files are on-demand via memory tools and are not auto-injected.
|
||||
- Workspace + bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md` when new, plus `MEMORY.md` and/or `memory.md` when present). Large files are truncated by `agents.defaults.bootstrapMaxChars` (default: 20000), and total bootstrap injection is capped by `agents.defaults.bootstrapTotalMaxChars` (default: 24000). `memory/*.md` files are on-demand via memory tools and are not auto-injected.
|
||||
- Time (UTC + user timezone)
|
||||
- Reply tags + heartbeat behavior
|
||||
- Runtime metadata (host/OS/model/thinking)
|
||||
|
||||
@@ -95,7 +95,7 @@ external end-user instructions.
|
||||
**OpenAI / OpenAI Codex**
|
||||
|
||||
- Image sanitization only.
|
||||
- Drop orphaned reasoning signatures (standalone reasoning items without a following content block) for OpenAI Responses/Codex transcripts.
|
||||
- On model switch into OpenAI Responses/Codex, drop orphaned reasoning signatures (standalone reasoning items without a following content block).
|
||||
- No tool call id sanitization.
|
||||
- No tool result pairing repair.
|
||||
- No turn validation or reordering.
|
||||
|
||||
@@ -224,35 +224,6 @@ Notes:
|
||||
- `log` supports line-based `offset`/`limit` (omit `offset` to grab the last N lines).
|
||||
- `process` is scoped per agent; sessions from other agents are not visible.
|
||||
|
||||
### `loop-detection` (tool-call loop guardrails)
|
||||
|
||||
OpenClaw tracks recent tool-call history and blocks or warns when it detects repetitive no-progress loops.
|
||||
Enable with `tools.loopDetection.enabled: true` (default is `false`).
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
loopDetection: {
|
||||
enabled: true,
|
||||
warningThreshold: 10,
|
||||
criticalThreshold: 20,
|
||||
globalCircuitBreakerThreshold: 30,
|
||||
historySize: 30,
|
||||
detectors: {
|
||||
genericRepeat: true,
|
||||
knownPollNoProgress: true,
|
||||
pingPong: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `genericRepeat`: repeated same tool + same params call pattern.
|
||||
- `knownPollNoProgress`: repeating poll-like tools with identical outputs.
|
||||
- `pingPong`: alternating `A/B/A/B` no-progress patterns.
|
||||
- Per-agent override: `agents.list[].tools.loopDetection`.
|
||||
|
||||
### `web_search`
|
||||
|
||||
Search the web using Brave Search API.
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
---
|
||||
title: "Tool-loop detection"
|
||||
description: "Configure optional guardrails for preventing repetitive or stalled tool-call loops"
|
||||
read_when:
|
||||
- A user reports agents getting stuck repeating tool calls
|
||||
- You need to tune repetitive-call protection
|
||||
- You are editing agent tool/runtime policies
|
||||
---
|
||||
|
||||
# Tool-loop detection
|
||||
|
||||
OpenClaw can keep agents from getting stuck in repeated tool-call patterns.
|
||||
The guard is **disabled by default**.
|
||||
|
||||
Enable it only where needed, because it can block legitimate repeated calls with strict settings.
|
||||
|
||||
## Why this exists
|
||||
|
||||
- Detect repetitive sequences that do not make progress.
|
||||
- Detect high-frequency no-result loops (same tool, same inputs, repeated errors).
|
||||
- Detect specific repeated-call patterns for known polling tools.
|
||||
|
||||
## Configuration block
|
||||
|
||||
Global defaults:
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
loopDetection: {
|
||||
enabled: false,
|
||||
historySize: 20,
|
||||
detectorCooldownMs: 12000,
|
||||
repeatThreshold: 3,
|
||||
criticalThreshold: 6,
|
||||
detectors: {
|
||||
repeatedFailure: true,
|
||||
knownPollLoop: true,
|
||||
repeatingNoProgress: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Per-agent override (optional):
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "safe-runner",
|
||||
tools: {
|
||||
loopDetection: {
|
||||
enabled: true,
|
||||
repeatThreshold: 2,
|
||||
criticalThreshold: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Field behavior
|
||||
|
||||
- `enabled`: Master switch. `false` means no loop detection is performed.
|
||||
- `historySize`: number of recent tool calls kept for analysis.
|
||||
- `detectorCooldownMs`: time window used by the no-progress detector.
|
||||
- `repeatThreshold`: minimum repeats before warning/blocking starts.
|
||||
- `criticalThreshold`: stronger threshold that can trigger stricter handling.
|
||||
- `detectors.repeatedFailure`: detects repeated failed attempts on the same call path.
|
||||
- `detectors.knownPollLoop`: detects known polling-like loops.
|
||||
- `detectors.repeatingNoProgress`: detects high-frequency repeated calls without state change.
|
||||
|
||||
## Recommended setup
|
||||
|
||||
- Start with `enabled: true`, defaults unchanged.
|
||||
- If false positives occur:
|
||||
- raise `repeatThreshold` and/or `criticalThreshold`
|
||||
- disable only the detector causing issues
|
||||
- reduce `historySize` for less strict historical context
|
||||
|
||||
## Logs and expected behavior
|
||||
|
||||
When a loop is detected, OpenClaw reports a loop event and blocks or dampens the next tool-cycle depending on severity.
|
||||
This protects users from runaway token spend and lockups while preserving normal tool access.
|
||||
|
||||
- Prefer warning and temporary suppression first.
|
||||
- Escalate only when repeated evidence accumulates.
|
||||
|
||||
## Notes
|
||||
|
||||
- `tools.loopDetection` is merged with agent-level overrides.
|
||||
- Per-agent config fully overrides or extends global values.
|
||||
- If no config exists, guardrails stay off.
|
||||
@@ -73,11 +73,9 @@ Text + native (when enabled):
|
||||
- `/commands`
|
||||
- `/skill <name> [input]` (run a skill by name)
|
||||
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
||||
- `/mesh <goal>` (auto-plan + run a workflow; also `/mesh plan|run|status|retry`, with `/mesh run <mesh-plan-id>` for exact plan replay in the same chat)
|
||||
- `/allowlist` (list/add/remove allowlist entries)
|
||||
- `/approve <id> allow-once|allow-always|deny` (resolve exec approval prompts)
|
||||
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||
- `/export-session [path]` (alias: `/export`) (export current session to HTML with full system prompt)
|
||||
- `/whoami` (show your sender id; alias: `/id`)
|
||||
- `/subagents list|kill|log|info|send|steer` (inspect, kill, log, or steer sub-agent runs for the current session)
|
||||
- `/kill <id|#|all>` (immediately abort one or all running sub-agents for this session; no confirmation message)
|
||||
|
||||
@@ -92,7 +92,6 @@ Cron jobs panel notes:
|
||||
|
||||
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
|
||||
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
|
||||
- `chat.history` responses are size-bounded for UI safety. When transcript entries are too large, Gateway may truncate long text fields, omit heavy metadata blocks, and replace oversized messages with a placeholder (`[chat.history omitted: message too large]`).
|
||||
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
|
||||
- Stop:
|
||||
- Click **Stop** (calls `chat.abort`)
|
||||
|
||||
@@ -24,7 +24,6 @@ Status: the macOS/iOS SwiftUI chat UI talks directly to the Gateway WebSocket.
|
||||
## How it works (behavior)
|
||||
|
||||
- The UI connects to the Gateway WebSocket and uses `chat.history`, `chat.send`, and `chat.inject`.
|
||||
- `chat.history` is bounded for stability: Gateway may truncate long text fields, omit heavy metadata, and replace oversized entries with `[chat.history omitted: message too large]`.
|
||||
- `chat.inject` appends an assistant note directly to the transcript and broadcasts it to the UI (no agent run).
|
||||
- Aborted runs can keep partial assistant output visible in the UI.
|
||||
- Gateway persists aborted partial assistant text into transcript history when buffered output exists, and marks those entries with abort metadata.
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
|
||||
export type BlueBubblesAccountResolveOpts = {
|
||||
serverUrl?: string;
|
||||
password?: string;
|
||||
accountId?: string;
|
||||
cfg?: OpenClawConfig;
|
||||
};
|
||||
|
||||
export function resolveBlueBubblesServerAccount(params: BlueBubblesAccountResolveOpts): {
|
||||
baseUrl: string;
|
||||
password: string;
|
||||
accountId: string;
|
||||
} {
|
||||
const account = resolveBlueBubblesAccount({
|
||||
cfg: params.cfg ?? {},
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
||||
const password = params.password?.trim() || account.config.password?.trim();
|
||||
if (!baseUrl) {
|
||||
throw new Error("BlueBubbles serverUrl is required");
|
||||
}
|
||||
if (!password) {
|
||||
throw new Error("BlueBubbles password is required");
|
||||
}
|
||||
return { baseUrl, password, accountId: account.accountId };
|
||||
}
|
||||
@@ -48,13 +48,6 @@ vi.mock("./probe.js", () => ({
|
||||
}));
|
||||
|
||||
describe("bluebubblesMessageActions", () => {
|
||||
const listActions = bluebubblesMessageActions.listActions!;
|
||||
const supportsAction = bluebubblesMessageActions.supportsAction!;
|
||||
const extractToolSend = bluebubblesMessageActions.extractToolSend!;
|
||||
const handleAction = bluebubblesMessageActions.handleAction!;
|
||||
const callHandleAction = (ctx: Omit<Parameters<typeof handleAction>[0], "channel">) =>
|
||||
handleAction({ channel: "bluebubbles", ...ctx });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
||||
@@ -65,7 +58,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { bluebubbles: { enabled: false } },
|
||||
};
|
||||
const actions = listActions({ cfg });
|
||||
const actions = bluebubblesMessageActions.listActions({ cfg });
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -73,7 +66,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { bluebubbles: { enabled: true } },
|
||||
};
|
||||
const actions = listActions({ cfg });
|
||||
const actions = bluebubblesMessageActions.listActions({ cfg });
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -87,7 +80,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const actions = listActions({ cfg });
|
||||
const actions = bluebubblesMessageActions.listActions({ cfg });
|
||||
expect(actions).toContain("react");
|
||||
});
|
||||
|
||||
@@ -102,7 +95,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const actions = listActions({ cfg });
|
||||
const actions = bluebubblesMessageActions.listActions({ cfg });
|
||||
expect(actions).not.toContain("react");
|
||||
// Other actions should still be present
|
||||
expect(actions).toContain("edit");
|
||||
@@ -120,7 +113,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const actions = listActions({ cfg });
|
||||
const actions = bluebubblesMessageActions.listActions({ cfg });
|
||||
expect(actions).toContain("sendAttachment");
|
||||
expect(actions).not.toContain("react");
|
||||
expect(actions).not.toContain("reply");
|
||||
@@ -137,31 +130,31 @@ describe("bluebubblesMessageActions", () => {
|
||||
|
||||
describe("supportsAction", () => {
|
||||
it("returns true for react action", () => {
|
||||
expect(supportsAction({ action: "react" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "react" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for all supported actions", () => {
|
||||
expect(supportsAction({ action: "edit" })).toBe(true);
|
||||
expect(supportsAction({ action: "unsend" })).toBe(true);
|
||||
expect(supportsAction({ action: "reply" })).toBe(true);
|
||||
expect(supportsAction({ action: "sendWithEffect" })).toBe(true);
|
||||
expect(supportsAction({ action: "renameGroup" })).toBe(true);
|
||||
expect(supportsAction({ action: "setGroupIcon" })).toBe(true);
|
||||
expect(supportsAction({ action: "addParticipant" })).toBe(true);
|
||||
expect(supportsAction({ action: "removeParticipant" })).toBe(true);
|
||||
expect(supportsAction({ action: "leaveGroup" })).toBe(true);
|
||||
expect(supportsAction({ action: "sendAttachment" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "edit" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "unsend" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "reply" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "sendWithEffect" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "renameGroup" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "setGroupIcon" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "addParticipant" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "removeParticipant" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "leaveGroup" })).toBe(true);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "sendAttachment" })).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for unsupported actions", () => {
|
||||
expect(supportsAction({ action: "delete" as never })).toBe(false);
|
||||
expect(supportsAction({ action: "unknown" as never })).toBe(false);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "delete" })).toBe(false);
|
||||
expect(bluebubblesMessageActions.supportsAction({ action: "unknown" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractToolSend", () => {
|
||||
it("extracts send params from sendMessage action", () => {
|
||||
const result = extractToolSend({
|
||||
const result = bluebubblesMessageActions.extractToolSend({
|
||||
args: {
|
||||
action: "sendMessage",
|
||||
to: "+15551234567",
|
||||
@@ -175,14 +168,14 @@ describe("bluebubblesMessageActions", () => {
|
||||
});
|
||||
|
||||
it("returns null for non-sendMessage action", () => {
|
||||
const result = extractToolSend({
|
||||
const result = bluebubblesMessageActions.extractToolSend({
|
||||
args: { action: "react", to: "+15551234567" },
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when to is missing", () => {
|
||||
const result = extractToolSend({
|
||||
const result = bluebubblesMessageActions.extractToolSend({
|
||||
args: { action: "sendMessage" },
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
@@ -200,8 +193,8 @@ describe("bluebubblesMessageActions", () => {
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
callHandleAction({
|
||||
action: "unknownAction" as never,
|
||||
bluebubblesMessageActions.handleAction({
|
||||
action: "unknownAction",
|
||||
params: {},
|
||||
cfg,
|
||||
accountId: null,
|
||||
@@ -219,7 +212,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
callHandleAction({
|
||||
bluebubblesMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: { messageId: "msg-123" },
|
||||
cfg,
|
||||
@@ -239,7 +232,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
callHandleAction({
|
||||
bluebubblesMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: { emoji: "❤️", messageId: "msg-123", chatGuid: "iMessage;-;+15551234567" },
|
||||
cfg,
|
||||
@@ -258,7 +251,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
callHandleAction({
|
||||
bluebubblesMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: { emoji: "❤️" },
|
||||
cfg,
|
||||
@@ -280,7 +273,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
callHandleAction({
|
||||
bluebubblesMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: { emoji: "❤️", messageId: "msg-123", to: "+15551234567" },
|
||||
cfg,
|
||||
@@ -300,7 +293,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await callHandleAction({
|
||||
const result = await bluebubblesMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
emoji: "❤️",
|
||||
@@ -335,7 +328,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await callHandleAction({
|
||||
const result = await bluebubblesMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
emoji: "❤️",
|
||||
@@ -371,7 +364,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
await callHandleAction({
|
||||
await bluebubblesMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
emoji: "👍",
|
||||
@@ -401,7 +394,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
await callHandleAction({
|
||||
await bluebubblesMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
emoji: "😂",
|
||||
@@ -433,7 +426,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
await callHandleAction({
|
||||
await bluebubblesMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
emoji: "👍",
|
||||
@@ -472,7 +465,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
await callHandleAction({
|
||||
await bluebubblesMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
emoji: "❤️",
|
||||
@@ -507,7 +500,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
};
|
||||
|
||||
await expect(
|
||||
callHandleAction({
|
||||
bluebubblesMessageActions.handleAction({
|
||||
action: "react",
|
||||
params: {
|
||||
emoji: "❤️",
|
||||
@@ -532,7 +525,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
await callHandleAction({
|
||||
await bluebubblesMessageActions.handleAction({
|
||||
action: "edit",
|
||||
params: { messageId: "msg-123", message: "updated" },
|
||||
cfg,
|
||||
@@ -558,7 +551,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = await callHandleAction({
|
||||
const result = await bluebubblesMessageActions.handleAction({
|
||||
action: "sendWithEffect",
|
||||
params: {
|
||||
message: "peekaboo",
|
||||
@@ -593,7 +586,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
|
||||
const base64Buffer = Buffer.from("voice").toString("base64");
|
||||
|
||||
await callHandleAction({
|
||||
await bluebubblesMessageActions.handleAction({
|
||||
action: "sendAttachment",
|
||||
params: {
|
||||
to: "+15551234567",
|
||||
@@ -626,7 +619,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
};
|
||||
|
||||
await expect(
|
||||
callHandleAction({
|
||||
bluebubblesMessageActions.handleAction({
|
||||
action: "setGroupIcon",
|
||||
params: { chatGuid: "iMessage;-;chat-guid" },
|
||||
cfg,
|
||||
@@ -651,7 +644,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
const testBuffer = Buffer.from("fake-image-data");
|
||||
const base64Buffer = testBuffer.toString("base64");
|
||||
|
||||
const result = await callHandleAction({
|
||||
const result = await bluebubblesMessageActions.handleAction({
|
||||
action: "setGroupIcon",
|
||||
params: {
|
||||
chatGuid: "iMessage;-;chat-guid",
|
||||
@@ -688,7 +681,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
|
||||
const base64Buffer = Buffer.from("test").toString("base64");
|
||||
|
||||
await callHandleAction({
|
||||
await bluebubblesMessageActions.handleAction({
|
||||
action: "setGroupIcon",
|
||||
params: {
|
||||
chatGuid: "iMessage;-;chat-guid",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type ChannelMessageActionName,
|
||||
type ChannelToolSend,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { BlueBubblesSendTarget } from "./types.js";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import {
|
||||
@@ -26,7 +27,6 @@ import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe
|
||||
import { sendBlueBubblesReaction } from "./reactions.js";
|
||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
|
||||
import type { BlueBubblesSendTarget } from "./types.js";
|
||||
|
||||
const providerId = "bluebubbles";
|
||||
|
||||
|
||||
@@ -1,18 +1,38 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "./test-mocks.js";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { BlueBubblesAttachment } from "./types.js";
|
||||
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
|
||||
import type { BlueBubblesAttachment } from "./types.js";
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
||||
const config = cfg?.channels?.bluebubbles ?? {};
|
||||
return {
|
||||
accountId: accountId ?? "default",
|
||||
enabled: config.enabled !== false,
|
||||
configured: Boolean(config.serverUrl && config.password),
|
||||
config,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
installBlueBubblesFetchTestHooks({
|
||||
mockFetch,
|
||||
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
|
||||
});
|
||||
|
||||
describe("downloadBlueBubblesAttachment", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("throws when guid is missing", async () => {
|
||||
const attachment: BlueBubblesAttachment = {};
|
||||
await expect(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { postMultipartFormData } from "./multipart.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
|
||||
@@ -54,7 +54,19 @@ function resolveVoiceInfo(filename: string, contentType?: string) {
|
||||
}
|
||||
|
||||
function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
||||
return resolveBlueBubblesServerAccount(params);
|
||||
const account = resolveBlueBubblesAccount({
|
||||
cfg: params.cfg ?? {},
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
||||
const password = params.password?.trim() || account.config.password?.trim();
|
||||
if (!baseUrl) {
|
||||
throw new Error("BlueBubbles serverUrl is required");
|
||||
}
|
||||
if (!password) {
|
||||
throw new Error("BlueBubbles password is required");
|
||||
}
|
||||
return { baseUrl, password, accountId: account.accountId };
|
||||
}
|
||||
|
||||
export async function downloadBlueBubblesAttachment(
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import "./test-mocks.js";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping, setGroupIconBlueBubbles } from "./chat.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
||||
const config = cfg?.channels?.bluebubbles ?? {};
|
||||
return {
|
||||
accountId: accountId ?? "default",
|
||||
enabled: config.enabled !== false,
|
||||
configured: Boolean(config.serverUrl && config.password),
|
||||
config,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
installBlueBubblesFetchTestHooks({
|
||||
mockFetch,
|
||||
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
|
||||
});
|
||||
|
||||
describe("chat", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("markBlueBubblesChatRead", () => {
|
||||
it("does nothing when chatGuid is empty", async () => {
|
||||
await markBlueBubblesChatRead("", {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { postMultipartFormData } from "./multipart.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
@@ -15,7 +15,19 @@ export type BlueBubblesChatOpts = {
|
||||
};
|
||||
|
||||
function resolveAccount(params: BlueBubblesChatOpts) {
|
||||
return resolveBlueBubblesServerAccount(params);
|
||||
const account = resolveBlueBubblesAccount({
|
||||
cfg: params.cfg ?? {},
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
||||
const password = params.password?.trim() || account.config.password?.trim();
|
||||
if (!baseUrl) {
|
||||
throw new Error("BlueBubbles serverUrl is required");
|
||||
}
|
||||
if (!password) {
|
||||
throw new Error("BlueBubbles password is required");
|
||||
}
|
||||
return { baseUrl, password, accountId: account.accountId };
|
||||
}
|
||||
|
||||
function assertPrivateApiEnabled(accountId: string, feature: string): void {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sendBlueBubblesMedia } from "./media-send.js";
|
||||
import { setBlueBubblesRuntime } from "./runtime.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { normalizeBlueBubblesHandle } from "./targets.js";
|
||||
import type { BlueBubblesAttachment } from "./types.js";
|
||||
import { normalizeBlueBubblesHandle } from "./targets.js";
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
resolveAckReaction,
|
||||
resolveControlCommandGate,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type {
|
||||
BlueBubblesCoreRuntime,
|
||||
BlueBubblesRuntimeEnv,
|
||||
WebhookTarget,
|
||||
} from "./monitor-shared.js";
|
||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
||||
import { sendBlueBubblesMedia } from "./media-send.js";
|
||||
@@ -27,11 +32,6 @@ import {
|
||||
resolveBlueBubblesMessageId,
|
||||
resolveReplyContextFromCache,
|
||||
} from "./monitor-reply-cache.js";
|
||||
import type {
|
||||
BlueBubblesCoreRuntime,
|
||||
BlueBubblesRuntimeEnv,
|
||||
WebhookTarget,
|
||||
} from "./monitor-shared.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
|
||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
import { getBlueBubblesRuntime } from "./runtime.js";
|
||||
import type { BlueBubblesAccountConfig } from "./types.js";
|
||||
import { getBlueBubblesRuntime } from "./runtime.js";
|
||||
|
||||
export type BlueBubblesRuntimeEnv = {
|
||||
log?: (message: string) => void;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { removeAckReactionAfterReply, shouldAckReaction } from "openclaw/plugin-sdk";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
@@ -52,22 +52,9 @@ const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]);
|
||||
const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) =>
|
||||
regexes.some((r) => r.test(text)),
|
||||
);
|
||||
const mockMatchesMentionWithExplicit = vi.fn(
|
||||
(params: { text: string; mentionRegexes: RegExp[]; explicitWasMentioned?: boolean }) => {
|
||||
if (params.explicitWasMentioned) {
|
||||
return true;
|
||||
}
|
||||
return params.mentionRegexes.some((regex) => regex.test(params.text));
|
||||
},
|
||||
);
|
||||
const mockResolveRequireMention = vi.fn(() => false);
|
||||
const mockResolveGroupPolicy = vi.fn(() => "open");
|
||||
type DispatchReplyParams = Parameters<
|
||||
PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"]
|
||||
>[0];
|
||||
const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(
|
||||
async (_params: DispatchReplyParams): Promise<void> => undefined,
|
||||
);
|
||||
const mockDispatchReplyWithBufferedBlockDispatcher = vi.fn(async () => undefined);
|
||||
const mockHasControlCommand = vi.fn(() => false);
|
||||
const mockResolveCommandAuthorizedFromAuthorizers = vi.fn(() => false);
|
||||
const mockSaveMediaBuffer = vi.fn().mockResolvedValue({
|
||||
@@ -82,10 +69,6 @@ const mockResolveEnvelopeFormatOptions = vi.fn(() => ({
|
||||
const mockFormatAgentEnvelope = vi.fn((opts: { body: string }) => opts.body);
|
||||
const mockFormatInboundEnvelope = vi.fn((opts: { body: string }) => opts.body);
|
||||
const mockChunkMarkdownText = vi.fn((text: string) => [text]);
|
||||
const mockChunkByNewline = vi.fn((text: string) => (text ? [text] : []));
|
||||
const mockChunkTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
||||
const mockChunkMarkdownTextWithMode = vi.fn((text: string) => (text ? [text] : []));
|
||||
const mockResolveChunkMode = vi.fn(() => "length");
|
||||
|
||||
function createMockRuntime(): PluginRuntime {
|
||||
return {
|
||||
@@ -98,9 +81,6 @@ function createMockRuntime(): PluginRuntime {
|
||||
enqueueSystemEvent:
|
||||
mockEnqueueSystemEvent as unknown as PluginRuntime["system"]["enqueueSystemEvent"],
|
||||
runCommandWithTimeout: vi.fn() as unknown as PluginRuntime["system"]["runCommandWithTimeout"],
|
||||
formatNativeDependencyHint: vi.fn(
|
||||
() => "",
|
||||
) as unknown as PluginRuntime["system"]["formatNativeDependencyHint"],
|
||||
},
|
||||
media: {
|
||||
loadWebMedia: vi.fn() as unknown as PluginRuntime["media"]["loadWebMedia"],
|
||||
@@ -111,9 +91,6 @@ function createMockRuntime(): PluginRuntime {
|
||||
getImageMetadata: vi.fn() as unknown as PluginRuntime["media"]["getImageMetadata"],
|
||||
resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"],
|
||||
},
|
||||
tts: {
|
||||
textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"],
|
||||
},
|
||||
tools: {
|
||||
createMemoryGetTool: vi.fn() as unknown as PluginRuntime["tools"]["createMemoryGetTool"],
|
||||
createMemorySearchTool:
|
||||
@@ -125,14 +102,6 @@ function createMockRuntime(): PluginRuntime {
|
||||
chunkMarkdownText:
|
||||
mockChunkMarkdownText as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownText"],
|
||||
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
|
||||
chunkByNewline:
|
||||
mockChunkByNewline as unknown as PluginRuntime["channel"]["text"]["chunkByNewline"],
|
||||
chunkMarkdownTextWithMode:
|
||||
mockChunkMarkdownTextWithMode as unknown as PluginRuntime["channel"]["text"]["chunkMarkdownTextWithMode"],
|
||||
chunkTextWithMode:
|
||||
mockChunkTextWithMode as unknown as PluginRuntime["channel"]["text"]["chunkTextWithMode"],
|
||||
resolveChunkMode:
|
||||
mockResolveChunkMode as unknown as PluginRuntime["channel"]["text"]["resolveChunkMode"],
|
||||
resolveTextChunkLimit: vi.fn(
|
||||
() => 4000,
|
||||
) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
|
||||
@@ -201,8 +170,6 @@ function createMockRuntime(): PluginRuntime {
|
||||
mockBuildMentionRegexes as unknown as PluginRuntime["channel"]["mentions"]["buildMentionRegexes"],
|
||||
matchesMentionPatterns:
|
||||
mockMatchesMentionPatterns as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionPatterns"],
|
||||
matchesMentionWithExplicit:
|
||||
mockMatchesMentionWithExplicit as unknown as PluginRuntime["channel"]["mentions"]["matchesMentionWithExplicit"],
|
||||
},
|
||||
reactions: {
|
||||
shouldAckReaction,
|
||||
@@ -239,8 +206,6 @@ function createMockRuntime(): PluginRuntime {
|
||||
vi.fn() as unknown as PluginRuntime["channel"]["commands"]["shouldHandleTextCommands"],
|
||||
},
|
||||
discord: {} as PluginRuntime["channel"]["discord"],
|
||||
activity: {} as PluginRuntime["channel"]["activity"],
|
||||
line: {} as PluginRuntime["channel"]["line"],
|
||||
slack: {} as PluginRuntime["channel"]["slack"],
|
||||
telegram: {} as PluginRuntime["channel"]["telegram"],
|
||||
signal: {} as PluginRuntime["channel"]["signal"],
|
||||
@@ -340,14 +305,6 @@ const flushAsync = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
@@ -1362,7 +1319,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = getFirstDispatchCall();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
expect(callArgs.ctx.WasMentioned).toBe(true);
|
||||
});
|
||||
|
||||
@@ -1484,7 +1441,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = getFirstDispatchCall();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
expect(callArgs.ctx.GroupSubject).toBe("Family");
|
||||
expect(callArgs.ctx.GroupMembers).toBe("Alice (+15551234567), Bob (+15557654321)");
|
||||
});
|
||||
@@ -1535,7 +1492,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
}),
|
||||
);
|
||||
// ConversationLabel should be the group label + id, not the sender
|
||||
const callArgs = getFirstDispatchCall();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
expect(callArgs.ctx.ConversationLabel).toBe("Family Chat id:iMessage;+;chat123456");
|
||||
expect(callArgs.ctx.SenderName).toBe("Alice");
|
||||
// BodyForAgent should be raw text, not the envelope-formatted body
|
||||
@@ -1624,7 +1581,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
sender: { name: "Alice", id: "+15551234567" },
|
||||
}),
|
||||
);
|
||||
const callArgs = getFirstDispatchCall();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
expect(callArgs.ctx.ConversationLabel).toBe("Alice id:+15551234567");
|
||||
});
|
||||
});
|
||||
@@ -1759,7 +1716,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||
const callArgs = getFirstDispatchCall();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
expect(callArgs.ctx.MediaPaths).toEqual(["/tmp/test-media.jpg"]);
|
||||
expect(callArgs.ctx.Body).toContain("hello");
|
||||
} finally {
|
||||
@@ -1808,7 +1765,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = getFirstDispatchCall();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
// ReplyToId is the full UUID since it wasn't previously cached
|
||||
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
|
||||
expect(callArgs.ctx.ReplyToBody).toBe("original message");
|
||||
@@ -1856,7 +1813,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = getFirstDispatchCall();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
expect(callArgs.ctx.ReplyToId).toBe("p:1/msg-0");
|
||||
expect(callArgs.ctx.ReplyToIdFull).toBe("p:1/msg-0");
|
||||
expect(callArgs.ctx.Body).toContain("[[reply_to:p:1/msg-0]]");
|
||||
@@ -1922,7 +1879,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = getFirstDispatchCall();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
// ReplyToId uses short ID "1" (first cached message) for token savings
|
||||
expect(callArgs.ctx.ReplyToId).toBe("1");
|
||||
expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
|
||||
@@ -1967,7 +1924,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = getFirstDispatchCall();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
|
||||
});
|
||||
});
|
||||
@@ -2007,7 +1964,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = getFirstDispatchCall();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
expect(callArgs.ctx.RawBody).toBe("Loved this idea");
|
||||
expect(callArgs.ctx.Body).toContain("Loved this idea");
|
||||
expect(callArgs.ctx.Body).not.toContain("reacted with");
|
||||
@@ -2047,7 +2004,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = getFirstDispatchCall();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
expect(callArgs.ctx.RawBody).toBe("reacted with 😅");
|
||||
expect(callArgs.ctx.Body).toContain("reacted with 😅");
|
||||
expect(callArgs.ctx.Body).not.toContain("[[reply_to:");
|
||||
@@ -2667,7 +2624,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
await flushAsync();
|
||||
|
||||
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
|
||||
const callArgs = getFirstDispatchCall();
|
||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||
// MessageSid should be short ID "1" instead of full UUID
|
||||
expect(callArgs.ctx.MessageSid).toBe("1");
|
||||
expect(callArgs.ctx.MessageSidFull).toBe("p:1/msg-uuid-12345");
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import {
|
||||
registerWebhookTarget,
|
||||
rejectNonPostWebhookRequest,
|
||||
resolveWebhookTargets,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
normalizeWebhookMessage,
|
||||
normalizeWebhookReaction,
|
||||
@@ -231,11 +226,20 @@ function removeDebouncer(target: WebhookTarget): void {
|
||||
}
|
||||
|
||||
export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => void {
|
||||
const registered = registerWebhookTarget(webhookTargets, target);
|
||||
const key = normalizeWebhookPath(target.path);
|
||||
const normalizedTarget = { ...target, path: key };
|
||||
const existing = webhookTargets.get(key) ?? [];
|
||||
const next = [...existing, normalizedTarget];
|
||||
webhookTargets.set(key, next);
|
||||
return () => {
|
||||
registered.unregister();
|
||||
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
|
||||
if (updated.length > 0) {
|
||||
webhookTargets.set(key, updated);
|
||||
} else {
|
||||
webhookTargets.delete(key);
|
||||
}
|
||||
// Clean up debouncer when target is unregistered
|
||||
removeDebouncer(registered.target);
|
||||
removeDebouncer(normalizedTarget);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -383,14 +387,17 @@ export async function handleBlueBubblesWebhookRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): Promise<boolean> {
|
||||
const resolved = resolveWebhookTargets(req, webhookTargets);
|
||||
if (!resolved) {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const path = normalizeWebhookPath(url.pathname);
|
||||
const targets = webhookTargets.get(path);
|
||||
if (!targets || targets.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const { path, targets } = resolved;
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
|
||||
if (rejectNonPostWebhookRequest(req, res)) {
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Allow", "POST");
|
||||
res.end("Method Not Allowed");
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
addWildcardAllowFrom,
|
||||
formatDocsLink,
|
||||
mergeAllowFromEntries,
|
||||
normalizeAccountId,
|
||||
promptAccountId,
|
||||
} from "openclaw/plugin-sdk";
|
||||
@@ -128,7 +127,7 @@ async function promptBlueBubblesAllowFrom(params: {
|
||||
},
|
||||
});
|
||||
const parts = parseBlueBubblesAllowFromInput(String(entry));
|
||||
const unique = mergeAllowFromEntries(undefined, parts);
|
||||
const unique = [...new Set(parts)];
|
||||
return setBlueBubblesAllowFrom(params.cfg, accountId, unique);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
@@ -112,7 +112,19 @@ const REACTION_EMOJIS = new Map<string, string>([
|
||||
]);
|
||||
|
||||
function resolveAccount(params: BlueBubblesReactionOpts) {
|
||||
return resolveBlueBubblesServerAccount(params);
|
||||
const account = resolveBlueBubblesAccount({
|
||||
cfg: params.cfg ?? {},
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
|
||||
const password = params.password?.trim() || account.config.password?.trim();
|
||||
if (!baseUrl) {
|
||||
throw new Error("BlueBubbles serverUrl is required");
|
||||
}
|
||||
if (!password) {
|
||||
throw new Error("BlueBubbles password is required");
|
||||
}
|
||||
return { baseUrl, password, accountId: account.accountId };
|
||||
}
|
||||
|
||||
export function normalizeBlueBubblesReactionInput(emoji: string, remove?: boolean): string {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
|
||||
import type { BlueBubblesSendTarget } from "./types.js";
|
||||
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
|
||||
|
||||
export function resolveBlueBubblesSendTarget(raw: string): BlueBubblesSendTarget {
|
||||
const parsed = parseBlueBubblesTarget(raw);
|
||||
|
||||
@@ -1,62 +1,39 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import "./test-mocks.js";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import type { BlueBubblesSendTarget } from "./types.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import { sendMessageBlueBubbles, resolveChatGuidForTarget } from "./send.js";
|
||||
import { installBlueBubblesFetchTestHooks } from "./test-harness.js";
|
||||
import type { BlueBubblesSendTarget } from "./types.js";
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveBlueBubblesAccount: vi.fn(({ cfg, accountId }) => {
|
||||
const config = cfg?.channels?.bluebubbles ?? {};
|
||||
return {
|
||||
accountId: accountId ?? "default",
|
||||
enabled: config.enabled !== false,
|
||||
configured: Boolean(config.serverUrl && config.password),
|
||||
config,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./probe.js", () => ({
|
||||
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
|
||||
installBlueBubblesFetchTestHooks({
|
||||
mockFetch,
|
||||
privateApiStatusMock: vi.mocked(getCachedBlueBubblesPrivateApiStatus),
|
||||
});
|
||||
|
||||
function mockResolvedHandleTarget(
|
||||
guid: string = "iMessage;-;+15551234567",
|
||||
address: string = "+15551234567",
|
||||
) {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid,
|
||||
participants: [{ address }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function mockSendResponse(body: unknown) {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify(body)),
|
||||
});
|
||||
}
|
||||
|
||||
describe("send", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
mockFetch.mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReset();
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("resolveChatGuidForTarget", () => {
|
||||
const resolveHandleTargetGuid = async (data: Array<Record<string, unknown>>) => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data }),
|
||||
});
|
||||
|
||||
const target: BlueBubblesSendTarget = {
|
||||
kind: "handle",
|
||||
address: "+15551234567",
|
||||
service: "imessage",
|
||||
};
|
||||
return await resolveChatGuidForTarget({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
target,
|
||||
});
|
||||
};
|
||||
|
||||
it("returns chatGuid directly for chat_guid target", async () => {
|
||||
const target: BlueBubblesSendTarget = {
|
||||
kind: "chat_guid",
|
||||
@@ -153,31 +130,65 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
it("resolves handle target by matching participant", async () => {
|
||||
const result = await resolveHandleTargetGuid([
|
||||
{
|
||||
guid: "iMessage;-;+15559999999",
|
||||
participants: [{ address: "+15559999999" }],
|
||||
},
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
]);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15559999999",
|
||||
participants: [{ address: "+15559999999" }],
|
||||
},
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const target: BlueBubblesSendTarget = {
|
||||
kind: "handle",
|
||||
address: "+15551234567",
|
||||
service: "imessage",
|
||||
};
|
||||
const result = await resolveChatGuidForTarget({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
target,
|
||||
});
|
||||
|
||||
expect(result).toBe("iMessage;-;+15551234567");
|
||||
});
|
||||
|
||||
it("prefers direct chat guid when handle also appears in a group chat", async () => {
|
||||
const result = await resolveHandleTargetGuid([
|
||||
{
|
||||
guid: "iMessage;+;group-123",
|
||||
participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
|
||||
},
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
]);
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;+;group-123",
|
||||
participants: [{ address: "+15551234567" }, { address: "+15550001111" }],
|
||||
},
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const target: BlueBubblesSendTarget = {
|
||||
kind: "handle",
|
||||
address: "+15551234567",
|
||||
service: "imessage",
|
||||
};
|
||||
const result = await resolveChatGuidForTarget({
|
||||
baseUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
target,
|
||||
});
|
||||
|
||||
expect(result).toBe("iMessage;-;+15551234567");
|
||||
});
|
||||
@@ -405,8 +416,28 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
it("sends message successfully", async () => {
|
||||
mockResolvedHandleTarget();
|
||||
mockSendResponse({ data: { guid: "msg-uuid-123" } });
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid: "msg-uuid-123" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello world!", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
@@ -425,8 +456,28 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
it("strips markdown formatting from outbound messages", async () => {
|
||||
mockResolvedHandleTarget();
|
||||
mockSendResponse({ data: { guid: "msg-uuid-stripped" } });
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid: "msg-uuid-stripped" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles(
|
||||
"+15551234567",
|
||||
@@ -527,8 +578,28 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
it("uses private-api when reply metadata is present", async () => {
|
||||
mockResolvedHandleTarget();
|
||||
mockSendResponse({ data: { guid: "msg-uuid-124" } });
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid: "msg-uuid-124" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15551234567", "Replying", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
@@ -549,8 +620,28 @@ describe("send", () => {
|
||||
|
||||
it("downgrades threaded reply to plain send when private API is disabled", async () => {
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValueOnce(false);
|
||||
mockResolvedHandleTarget();
|
||||
mockSendResponse({ data: { guid: "msg-uuid-plain" } });
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid: "msg-uuid-plain" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15551234567", "Reply fallback", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
@@ -568,8 +659,28 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
it("normalizes effect names and uses private-api for effects", async () => {
|
||||
mockResolvedHandleTarget();
|
||||
mockSendResponse({ data: { guid: "msg-uuid-125" } });
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid: "msg-uuid-125" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
@@ -611,12 +722,24 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
it("handles send failure", async () => {
|
||||
mockResolvedHandleTarget();
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve("Internal server error"),
|
||||
});
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: () => Promise.resolve("Internal server error"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||
@@ -627,11 +750,23 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
it("handles empty response body", async () => {
|
||||
mockResolvedHandleTarget();
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(""),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
@@ -642,11 +777,23 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
it("handles invalid JSON response body", async () => {
|
||||
mockResolvedHandleTarget();
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve("not valid json"),
|
||||
});
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve("not valid json"),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
@@ -657,8 +804,28 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
it("extracts messageId from various response formats", async () => {
|
||||
mockResolvedHandleTarget();
|
||||
mockSendResponse({ id: "numeric-id-456" });
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
id: "numeric-id-456",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
@@ -669,8 +836,28 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
it("extracts messageGuid from response payload", async () => {
|
||||
mockResolvedHandleTarget();
|
||||
mockSendResponse({ data: { messageGuid: "msg-guid-789" } });
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { messageGuid: "msg-guid-789" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
@@ -681,8 +868,23 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
it("resolves credentials from config", async () => {
|
||||
mockResolvedHandleTarget();
|
||||
mockSendResponse({ data: { guid: "msg-123" } });
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg-123" } })),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||
cfg: {
|
||||
@@ -701,8 +903,23 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
it("includes tempGuid in request payload", async () => {
|
||||
mockResolvedHandleTarget();
|
||||
mockSendResponse({ data: { guid: "msg" } });
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(JSON.stringify({ data: { guid: "msg" } })),
|
||||
});
|
||||
|
||||
await sendMessageBlueBubbles("+15551234567", "Hello", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import crypto from "node:crypto";
|
||||
import { stripMarkdown } from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
isAllowedParsedChatSender,
|
||||
parseChatAllowTargetPrefixes,
|
||||
parseChatTargetPrefixesOrThrow,
|
||||
resolveServicePrefixedAllowTarget,
|
||||
@@ -330,15 +329,43 @@ export function isAllowedBlueBubblesSender(params: {
|
||||
chatGuid?: string | null;
|
||||
chatIdentifier?: string | null;
|
||||
}): boolean {
|
||||
return isAllowedParsedChatSender({
|
||||
allowFrom: params.allowFrom,
|
||||
sender: params.sender,
|
||||
chatId: params.chatId,
|
||||
chatGuid: params.chatGuid,
|
||||
chatIdentifier: params.chatIdentifier,
|
||||
normalizeSender: normalizeBlueBubblesHandle,
|
||||
parseAllowTarget: parseBlueBubblesAllowTarget,
|
||||
});
|
||||
const allowFrom = params.allowFrom.map((entry) => String(entry).trim());
|
||||
if (allowFrom.length === 0) {
|
||||
return true;
|
||||
}
|
||||
if (allowFrom.includes("*")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const senderNormalized = normalizeBlueBubblesHandle(params.sender);
|
||||
const chatId = params.chatId ?? undefined;
|
||||
const chatGuid = params.chatGuid?.trim();
|
||||
const chatIdentifier = params.chatIdentifier?.trim();
|
||||
|
||||
for (const entry of allowFrom) {
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseBlueBubblesAllowTarget(entry);
|
||||
if (parsed.kind === "chat_id" && chatId !== undefined) {
|
||||
if (parsed.chatId === chatId) {
|
||||
return true;
|
||||
}
|
||||
} else if (parsed.kind === "chat_guid" && chatGuid) {
|
||||
if (parsed.chatGuid === chatGuid) {
|
||||
return true;
|
||||
}
|
||||
} else if (parsed.kind === "chat_identifier" && chatIdentifier) {
|
||||
if (parsed.chatIdentifier === chatIdentifier) {
|
||||
return true;
|
||||
}
|
||||
} else if (parsed.kind === "handle" && senderNormalized) {
|
||||
if (parsed.handle === senderNormalized) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function formatBlueBubblesChatTarget(params: {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { Mock } from "vitest";
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
|
||||
export function resolveBlueBubblesAccountFromConfig(params: {
|
||||
cfg?: { channels?: { bluebubbles?: Record<string, unknown> } };
|
||||
accountId?: string;
|
||||
}) {
|
||||
const config = params.cfg?.channels?.bluebubbles ?? {};
|
||||
return {
|
||||
accountId: params.accountId ?? "default",
|
||||
enabled: config.enabled !== false,
|
||||
configured: Boolean(config.serverUrl && config.password),
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
export function createBlueBubblesAccountsMockModule() {
|
||||
return {
|
||||
resolveBlueBubblesAccount: vi.fn(resolveBlueBubblesAccountFromConfig),
|
||||
};
|
||||
}
|
||||
|
||||
type BlueBubblesProbeMockModule = {
|
||||
getCachedBlueBubblesPrivateApiStatus: Mock<() => boolean | null>;
|
||||
};
|
||||
|
||||
export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule {
|
||||
return {
|
||||
getCachedBlueBubblesPrivateApiStatus: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
}
|
||||
|
||||
export function installBlueBubblesFetchTestHooks(params: {
|
||||
mockFetch: ReturnType<typeof vi.fn>;
|
||||
privateApiStatusMock: {
|
||||
mockReset: () => unknown;
|
||||
mockReturnValue: (value: boolean | null) => unknown;
|
||||
};
|
||||
}) {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", params.mockFetch);
|
||||
params.mockFetch.mockReset();
|
||||
params.privateApiStatusMock.mockReset();
|
||||
params.privateApiStatusMock.mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("./accounts.js", async () => {
|
||||
const { createBlueBubblesAccountsMockModule } = await import("./test-harness.js");
|
||||
return createBlueBubblesAccountsMockModule();
|
||||
});
|
||||
|
||||
vi.mock("./probe.js", async () => {
|
||||
const { createBlueBubblesProbeMockModule } = await import("./test-harness.js");
|
||||
return createBlueBubblesProbeMockModule();
|
||||
});
|
||||
@@ -1,15 +1,6 @@
|
||||
import os from "node:os";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import os from "node:os";
|
||||
import { approveDevicePairing, listDevicePairing } from "openclaw/plugin-sdk";
|
||||
import qrcode from "qrcode-terminal";
|
||||
|
||||
function renderQrAscii(data: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
qrcode.generate(data, { small: true }, (output: string) => {
|
||||
resolve(output);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const DEFAULT_GATEWAY_PORT = 18789;
|
||||
|
||||
@@ -129,7 +120,7 @@ function isTailnetIPv4(address: string): boolean {
|
||||
return a === 100 && b >= 64 && b <= 127;
|
||||
}
|
||||
|
||||
function pickMatchingIPv4(predicate: (address: string) => boolean): string | null {
|
||||
function pickLanIPv4(): string | null {
|
||||
const nets = os.networkInterfaces();
|
||||
for (const entries of Object.values(nets)) {
|
||||
if (!entries) {
|
||||
@@ -146,7 +137,7 @@ function pickMatchingIPv4(predicate: (address: string) => boolean): string | nul
|
||||
if (!address) {
|
||||
continue;
|
||||
}
|
||||
if (predicate(address)) {
|
||||
if (isPrivateIPv4(address)) {
|
||||
return address;
|
||||
}
|
||||
}
|
||||
@@ -154,12 +145,29 @@ function pickMatchingIPv4(predicate: (address: string) => boolean): string | nul
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickLanIPv4(): string | null {
|
||||
return pickMatchingIPv4(isPrivateIPv4);
|
||||
}
|
||||
|
||||
function pickTailnetIPv4(): string | null {
|
||||
return pickMatchingIPv4(isTailnetIPv4);
|
||||
const nets = os.networkInterfaces();
|
||||
for (const entries of Object.values(nets)) {
|
||||
if (!entries) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const family = entry?.family;
|
||||
// Check for IPv4 (string "IPv4" on Node 18+, number 4 on older)
|
||||
const isIpv4 = family === "IPv4" || String(family) === "4";
|
||||
if (!entry || entry.internal || !isIpv4) {
|
||||
continue;
|
||||
}
|
||||
const address = entry.address?.trim() ?? "";
|
||||
if (!address) {
|
||||
continue;
|
||||
}
|
||||
if (isTailnetIPv4(address)) {
|
||||
return address;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resolveTailnetHost(api: OpenClawPluginApi): Promise<string | null> {
|
||||
@@ -443,69 +451,6 @@ export default function register(api: OpenClawPluginApi) {
|
||||
password: auth.password,
|
||||
};
|
||||
|
||||
if (action === "qr") {
|
||||
const setupCode = encodeSetupCode(payload);
|
||||
const qrAscii = await renderQrAscii(setupCode);
|
||||
const authLabel = auth.label ?? "auth";
|
||||
|
||||
const channel = ctx.channel;
|
||||
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
|
||||
|
||||
if (channel === "telegram" && target) {
|
||||
try {
|
||||
const send = api.runtime?.channel?.telegram?.sendMessageTelegram;
|
||||
if (send) {
|
||||
await send(
|
||||
target,
|
||||
["Scan this QR code with the OpenClaw iOS app:", "", "```", qrAscii, "```"].join(
|
||||
"\n",
|
||||
),
|
||||
{
|
||||
...(ctx.messageThreadId != null ? { messageThreadId: ctx.messageThreadId } : {}),
|
||||
...(ctx.accountId ? { accountId: ctx.accountId } : {}),
|
||||
},
|
||||
);
|
||||
return {
|
||||
text: [
|
||||
`Gateway: ${payload.url}`,
|
||||
`Auth: ${authLabel}`,
|
||||
"",
|
||||
"After scanning, come back here and run `/pair approve` to complete pairing.",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
api.logger.warn?.(
|
||||
`device-pair: telegram QR send failed, falling back (${String(
|
||||
(err as Error)?.message ?? err,
|
||||
)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Render based on channel capability
|
||||
api.logger.info?.(`device-pair: QR fallback channel=${channel} target=${target}`);
|
||||
const infoLines = [
|
||||
`Gateway: ${payload.url}`,
|
||||
`Auth: ${authLabel}`,
|
||||
"",
|
||||
"After scanning, run `/pair approve` to complete pairing.",
|
||||
];
|
||||
|
||||
// WebUI + CLI/TUI: ASCII QR
|
||||
return {
|
||||
text: [
|
||||
"Scan this QR code with the OpenClaw iOS app:",
|
||||
"",
|
||||
"```",
|
||||
qrAscii,
|
||||
"```",
|
||||
"",
|
||||
...infoLines,
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
const channel = ctx.channel;
|
||||
const target = ctx.senderId?.trim() || ctx.from?.trim() || ctx.to?.trim() || "";
|
||||
const authLabel = auth.label ?? "auth";
|
||||
|
||||
@@ -104,7 +104,6 @@ vi.mock("openclaw/plugin-sdk", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import type { OpenClawPluginServiceContext } from "openclaw/plugin-sdk";
|
||||
import { emitDiagnosticEvent } from "openclaw/plugin-sdk";
|
||||
import { createDiagnosticsOtelService } from "./service.js";
|
||||
|
||||
@@ -131,7 +130,7 @@ describe("diagnostics-otel service", () => {
|
||||
});
|
||||
|
||||
const service = createDiagnosticsOtelService();
|
||||
const ctx: OpenClawPluginServiceContext = {
|
||||
await service.start({
|
||||
config: {
|
||||
diagnostics: {
|
||||
enabled: true,
|
||||
@@ -151,9 +150,7 @@ describe("diagnostics-otel service", () => {
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
stateDir: "/tmp/openclaw-diagnostics-otel-test",
|
||||
};
|
||||
await service.start(ctx);
|
||||
});
|
||||
|
||||
emitDiagnosticEvent({
|
||||
type: "webhook.received",
|
||||
@@ -225,6 +222,6 @@ describe("diagnostics-otel service", () => {
|
||||
});
|
||||
expect(logEmit).toHaveBeenCalled();
|
||||
|
||||
await service.stop?.(ctx);
|
||||
await service.stop?.();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { metrics, trace, SpanStatusCode } from "@opentelemetry/api";
|
||||
import type { SeverityNumber } from "@opentelemetry/api-logs";
|
||||
import type { DiagnosticEventPayload, OpenClawPluginService } from "openclaw/plugin-sdk";
|
||||
import { metrics, trace, SpanStatusCode } from "@opentelemetry/api";
|
||||
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
|
||||
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
||||
@@ -9,7 +10,6 @@ import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
|
||||
import { NodeSDK } from "@opentelemetry/sdk-node";
|
||||
import { ParentBasedSampler, TraceIdRatioBasedSampler } from "@opentelemetry/sdk-trace-base";
|
||||
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions";
|
||||
import type { DiagnosticEventPayload, OpenClawPluginService } from "openclaw/plugin-sdk";
|
||||
import { onDiagnosticEvent, registerLogTransport } from "openclaw/plugin-sdk";
|
||||
|
||||
const DEFAULT_SERVICE_NAME = "openclaw";
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
normalizeDiscordMessagingTarget,
|
||||
normalizeDiscordOutboundTarget,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveDiscordAccount,
|
||||
resolveDefaultDiscordAccountId,
|
||||
@@ -292,7 +291,6 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
chunker: null,
|
||||
textChunkLimit: 2000,
|
||||
pollMaxOptions: 10,
|
||||
resolveTarget: ({ to }) => normalizeDiscordOutboundTarget(to),
|
||||
sendText: async ({ to, text, accountId, deps, replyToId, silent }) => {
|
||||
const send = deps?.sendDiscord ?? getDiscordRuntime().channel.discord.sendMessageDiscord;
|
||||
const result = await send(to, text, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { FeishuConfig } from "./types.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
|
||||
// ============ Helpers ============
|
||||
|
||||
@@ -224,198 +224,6 @@ async function createRecord(
|
||||
};
|
||||
}
|
||||
|
||||
/** Logger interface for cleanup operations */
|
||||
type CleanupLogger = {
|
||||
debug: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
};
|
||||
|
||||
/** Default field types created for new Bitable tables (to be cleaned up) */
|
||||
const DEFAULT_CLEANUP_FIELD_TYPES = new Set([3, 5, 17]); // SingleSelect, DateTime, Attachment
|
||||
|
||||
/** Clean up default placeholder rows and fields in a newly created Bitable table */
|
||||
async function cleanupNewBitable(
|
||||
client: ReturnType<typeof createFeishuClient>,
|
||||
appToken: string,
|
||||
tableId: string,
|
||||
tableName: string,
|
||||
logger: CleanupLogger,
|
||||
): Promise<{ cleanedRows: number; cleanedFields: number }> {
|
||||
let cleanedRows = 0;
|
||||
let cleanedFields = 0;
|
||||
|
||||
// Step 1: Clean up default fields
|
||||
const fieldsRes = await client.bitable.appTableField.list({
|
||||
path: { app_token: appToken, table_id: tableId },
|
||||
});
|
||||
|
||||
if (fieldsRes.code === 0 && fieldsRes.data?.items) {
|
||||
// Step 1a: Rename primary field to the table name (works for both Feishu and Lark)
|
||||
const primaryField = fieldsRes.data.items.find((f) => f.is_primary);
|
||||
if (primaryField?.field_id) {
|
||||
try {
|
||||
const newFieldName = tableName.length <= 20 ? tableName : "Name";
|
||||
await client.bitable.appTableField.update({
|
||||
path: {
|
||||
app_token: appToken,
|
||||
table_id: tableId,
|
||||
field_id: primaryField.field_id,
|
||||
},
|
||||
data: {
|
||||
field_name: newFieldName,
|
||||
type: 1,
|
||||
},
|
||||
});
|
||||
cleanedFields++;
|
||||
} catch (err) {
|
||||
logger.debug(`Failed to rename primary field: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1b: Delete default placeholder fields by type (works for both Feishu and Lark)
|
||||
const defaultFieldsToDelete = fieldsRes.data.items.filter(
|
||||
(f) => !f.is_primary && DEFAULT_CLEANUP_FIELD_TYPES.has(f.type ?? 0),
|
||||
);
|
||||
|
||||
for (const field of defaultFieldsToDelete) {
|
||||
if (field.field_id) {
|
||||
try {
|
||||
await client.bitable.appTableField.delete({
|
||||
path: {
|
||||
app_token: appToken,
|
||||
table_id: tableId,
|
||||
field_id: field.field_id,
|
||||
},
|
||||
});
|
||||
cleanedFields++;
|
||||
} catch (err) {
|
||||
logger.debug(`Failed to delete default field ${field.field_name}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Delete empty placeholder rows (batch when possible)
|
||||
const recordsRes = await client.bitable.appTableRecord.list({
|
||||
path: { app_token: appToken, table_id: tableId },
|
||||
params: { page_size: 100 },
|
||||
});
|
||||
|
||||
if (recordsRes.code === 0 && recordsRes.data?.items) {
|
||||
const emptyRecordIds = recordsRes.data.items
|
||||
.filter((r) => !r.fields || Object.keys(r.fields).length === 0)
|
||||
.map((r) => r.record_id)
|
||||
.filter((id): id is string => Boolean(id));
|
||||
|
||||
if (emptyRecordIds.length > 0) {
|
||||
try {
|
||||
await client.bitable.appTableRecord.batchDelete({
|
||||
path: { app_token: appToken, table_id: tableId },
|
||||
data: { records: emptyRecordIds },
|
||||
});
|
||||
cleanedRows = emptyRecordIds.length;
|
||||
} catch {
|
||||
// Fallback: delete one by one if batch API is unavailable
|
||||
for (const recordId of emptyRecordIds) {
|
||||
try {
|
||||
await client.bitable.appTableRecord.delete({
|
||||
path: { app_token: appToken, table_id: tableId, record_id: recordId },
|
||||
});
|
||||
cleanedRows++;
|
||||
} catch (err) {
|
||||
logger.debug(`Failed to delete empty row ${recordId}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { cleanedRows, cleanedFields };
|
||||
}
|
||||
|
||||
async function createApp(
|
||||
client: ReturnType<typeof createFeishuClient>,
|
||||
name: string,
|
||||
folderToken?: string,
|
||||
logger?: CleanupLogger,
|
||||
) {
|
||||
const res = await client.bitable.app.create({
|
||||
data: {
|
||||
name,
|
||||
...(folderToken && { folder_token: folderToken }),
|
||||
},
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
const appToken = res.data?.app?.app_token;
|
||||
if (!appToken) {
|
||||
throw new Error("Failed to create Bitable: no app_token returned");
|
||||
}
|
||||
|
||||
const log: CleanupLogger = logger ?? { debug: () => {}, warn: () => {} };
|
||||
let tableId: string | undefined;
|
||||
let cleanedRows = 0;
|
||||
let cleanedFields = 0;
|
||||
|
||||
try {
|
||||
const tablesRes = await client.bitable.appTable.list({
|
||||
path: { app_token: appToken },
|
||||
});
|
||||
if (tablesRes.code === 0 && tablesRes.data?.items && tablesRes.data.items.length > 0) {
|
||||
tableId = tablesRes.data.items[0].table_id ?? undefined;
|
||||
if (tableId) {
|
||||
const cleanup = await cleanupNewBitable(client, appToken, tableId, name, log);
|
||||
cleanedRows = cleanup.cleanedRows;
|
||||
cleanedFields = cleanup.cleanedFields;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.debug(`Cleanup failed (non-critical): ${err}`);
|
||||
}
|
||||
|
||||
return {
|
||||
app_token: appToken,
|
||||
table_id: tableId,
|
||||
name: res.data?.app?.name,
|
||||
url: res.data?.app?.url,
|
||||
cleaned_placeholder_rows: cleanedRows,
|
||||
cleaned_default_fields: cleanedFields,
|
||||
hint: tableId
|
||||
? `Table created. Use app_token="${appToken}" and table_id="${tableId}" for other bitable tools.`
|
||||
: "Table created. Use feishu_bitable_get_meta to get table_id and field details.",
|
||||
};
|
||||
}
|
||||
|
||||
async function createField(
|
||||
client: ReturnType<typeof createFeishuClient>,
|
||||
appToken: string,
|
||||
tableId: string,
|
||||
fieldName: string,
|
||||
fieldType: number,
|
||||
property?: Record<string, unknown>,
|
||||
) {
|
||||
const res = await client.bitable.appTableField.create({
|
||||
path: { app_token: appToken, table_id: tableId },
|
||||
data: {
|
||||
field_name: fieldName,
|
||||
type: fieldType,
|
||||
...(property && { property }),
|
||||
},
|
||||
});
|
||||
if (res.code !== 0) {
|
||||
throw new Error(res.msg);
|
||||
}
|
||||
|
||||
return {
|
||||
field_id: res.data?.field?.field_id,
|
||||
field_name: res.data?.field?.field_name,
|
||||
type: res.data?.field?.type,
|
||||
type_name: FIELD_TYPE_NAMES[res.data?.field?.type ?? 0] || `type_${res.data?.field?.type}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function updateRecord(
|
||||
client: ReturnType<typeof createFeishuClient>,
|
||||
appToken: string,
|
||||
@@ -488,36 +296,6 @@ const CreateRecordSchema = Type.Object({
|
||||
}),
|
||||
});
|
||||
|
||||
const CreateAppSchema = Type.Object({
|
||||
name: Type.String({
|
||||
description: "Name for the new Bitable application",
|
||||
}),
|
||||
folder_token: Type.Optional(
|
||||
Type.String({
|
||||
description: "Optional folder token to place the Bitable in a specific folder",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const CreateFieldSchema = Type.Object({
|
||||
app_token: Type.String({
|
||||
description:
|
||||
"Bitable app token (use feishu_bitable_get_meta to get from URL, or feishu_bitable_create_app to create new)",
|
||||
}),
|
||||
table_id: Type.String({ description: "Table ID (from URL: ?table=YYY)" }),
|
||||
field_name: Type.String({ description: "Name for the new field" }),
|
||||
field_type: Type.Number({
|
||||
description:
|
||||
"Field type ID: 1=Text, 2=Number, 3=SingleSelect, 4=MultiSelect, 5=DateTime, 7=Checkbox, 11=User, 13=Phone, 15=URL, 17=Attachment, 18=SingleLink, 19=Lookup, 20=Formula, 21=DuplexLink, 22=Location, 23=GroupChat, 1001=CreatedTime, 1002=ModifiedTime, 1003=CreatedUser, 1004=ModifiedUser, 1005=AutoNumber",
|
||||
minimum: 1,
|
||||
}),
|
||||
property: Type.Optional(
|
||||
Type.Record(Type.String(), Type.Any(), {
|
||||
description: "Field-specific properties (e.g., options for SingleSelect, format for Number)",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const UpdateRecordSchema = Type.Object({
|
||||
app_token: Type.String({
|
||||
description: "Bitable app token (use feishu_bitable_get_meta to get from URL)",
|
||||
@@ -679,61 +457,5 @@ export function registerFeishuBitableTools(api: OpenClawPluginApi) {
|
||||
{ name: "feishu_bitable_update_record" },
|
||||
);
|
||||
|
||||
// Tool 6: feishu_bitable_create_app
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_bitable_create_app",
|
||||
label: "Feishu Bitable Create App",
|
||||
description: "Create a new Bitable (multidimensional table) application",
|
||||
parameters: CreateAppSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const { name, folder_token } = params as { name: string; folder_token?: string };
|
||||
try {
|
||||
const result = await createApp(getClient(), name, folder_token, {
|
||||
debug: (msg) => api.logger.debug?.(msg),
|
||||
warn: (msg) => api.logger.warn?.(msg),
|
||||
});
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "feishu_bitable_create_app" },
|
||||
);
|
||||
|
||||
// Tool 7: feishu_bitable_create_field
|
||||
api.registerTool(
|
||||
{
|
||||
name: "feishu_bitable_create_field",
|
||||
label: "Feishu Bitable Create Field",
|
||||
description: "Create a new field (column) in a Bitable table",
|
||||
parameters: CreateFieldSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const { app_token, table_id, field_name, field_type, property } = params as {
|
||||
app_token: string;
|
||||
table_id: string;
|
||||
field_name: string;
|
||||
field_type: number;
|
||||
property?: Record<string, unknown>;
|
||||
};
|
||||
try {
|
||||
const result = await createField(
|
||||
getClient(),
|
||||
app_token,
|
||||
table_id,
|
||||
field_name,
|
||||
field_type,
|
||||
property,
|
||||
);
|
||||
return json(result);
|
||||
} catch (err) {
|
||||
return json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: "feishu_bitable_create_field" },
|
||||
);
|
||||
|
||||
api.logger.info?.("feishu_bitable: Registered bitable tools");
|
||||
api.logger.info?.(`feishu_bitable: Registered 6 bitable tools`);
|
||||
}
|
||||
|
||||
@@ -61,46 +61,4 @@ describe("parseFeishuMessageEvent – mentionedBot", () => {
|
||||
const ctx = parseFeishuMessageEvent(event as any, "");
|
||||
expect(ctx.mentionedBot).toBe(false);
|
||||
});
|
||||
|
||||
it("returns mentionedBot=true for post message with at (no top-level mentions)", () => {
|
||||
const BOT_OPEN_ID = "ou_bot_123";
|
||||
const postContent = JSON.stringify({
|
||||
content: [
|
||||
[{ tag: "at", user_id: BOT_OPEN_ID, user_name: "claw" }],
|
||||
[{ tag: "text", text: "What does this document say" }],
|
||||
],
|
||||
});
|
||||
const event = {
|
||||
sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } },
|
||||
message: {
|
||||
message_id: "msg_1",
|
||||
chat_id: "oc_chat1",
|
||||
chat_type: "group",
|
||||
message_type: "post",
|
||||
content: postContent,
|
||||
mentions: [],
|
||||
},
|
||||
};
|
||||
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
|
||||
expect(ctx.mentionedBot).toBe(true);
|
||||
});
|
||||
|
||||
it("returns mentionedBot=false for post message with no at", () => {
|
||||
const postContent = JSON.stringify({
|
||||
content: [[{ tag: "text", text: "hello" }]],
|
||||
});
|
||||
const event = {
|
||||
sender: { sender_id: { user_id: "u1", open_id: "ou_sender" } },
|
||||
message: {
|
||||
message_id: "msg_1",
|
||||
chat_id: "oc_chat1",
|
||||
chat_type: "group",
|
||||
message_type: "post",
|
||||
content: postContent,
|
||||
mentions: [],
|
||||
},
|
||||
};
|
||||
const ctx = parseFeishuMessageEvent(event as any, "ou_bot_123");
|
||||
expect(ctx.mentionedBot).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,13 +99,7 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
await handleFeishuMessage({
|
||||
cfg,
|
||||
event,
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}),
|
||||
} as RuntimeEnv,
|
||||
runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
|
||||
});
|
||||
|
||||
expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
|
||||
@@ -154,13 +148,7 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
await handleFeishuMessage({
|
||||
cfg,
|
||||
event,
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}),
|
||||
} as RuntimeEnv,
|
||||
runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
|
||||
});
|
||||
|
||||
expect(mockReadAllowFromStore).toHaveBeenCalledWith("feishu");
|
||||
@@ -201,13 +189,7 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
await handleFeishuMessage({
|
||||
cfg,
|
||||
event,
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}),
|
||||
} as RuntimeEnv,
|
||||
runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
|
||||
});
|
||||
|
||||
expect(mockUpsertPairingRequest).toHaveBeenCalledWith({
|
||||
@@ -265,13 +247,7 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
await handleFeishuMessage({
|
||||
cfg,
|
||||
event,
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}),
|
||||
} as RuntimeEnv,
|
||||
runtime: { log: vi.fn(), error: vi.fn() } as RuntimeEnv,
|
||||
});
|
||||
|
||||
expect(mockResolveCommandAuthorizedFromAuthorizers).toHaveBeenCalledWith({
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
DEFAULT_GROUP_HISTORY_LIMIT,
|
||||
type HistoryEntry,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
|
||||
import type { DynamicAgentCreationConfig } from "./types.js";
|
||||
import { resolveFeishuAccount } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { tryRecordMessage } from "./dedup.js";
|
||||
@@ -22,8 +24,6 @@ import {
|
||||
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { getMessageFeishu, sendMessageFeishu } from "./send.js";
|
||||
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
|
||||
import type { DynamicAgentCreationConfig } from "./types.js";
|
||||
|
||||
// --- Permission error extraction ---
|
||||
// Extract permission grant URL from Feishu API error response.
|
||||
@@ -185,17 +185,10 @@ function parseMessageContent(content: string, messageType: string): string {
|
||||
}
|
||||
|
||||
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
|
||||
if (!botOpenId) return false;
|
||||
const mentions = event.message.mentions ?? [];
|
||||
if (mentions.length > 0) {
|
||||
return mentions.some((m) => m.id.open_id === botOpenId);
|
||||
}
|
||||
// Post (rich text) messages may have empty message.mentions when they contain docs/paste
|
||||
if (event.message.message_type === "post") {
|
||||
const { mentionedOpenIds } = parsePostContent(event.message.content);
|
||||
return mentionedOpenIds.some((id) => id === botOpenId);
|
||||
}
|
||||
return false;
|
||||
if (mentions.length === 0) return false;
|
||||
if (!botOpenId) return false;
|
||||
return mentions.some((m) => m.id.open_id === botOpenId);
|
||||
}
|
||||
|
||||
function stripBotMention(
|
||||
@@ -251,7 +244,6 @@ function parseMediaKeys(
|
||||
function parsePostContent(content: string): {
|
||||
textContent: string;
|
||||
imageKeys: string[];
|
||||
mentionedOpenIds: string[];
|
||||
} {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
@@ -259,7 +251,6 @@ function parsePostContent(content: string): {
|
||||
const contentBlocks = parsed.content || [];
|
||||
let textContent = title ? `${title}\n\n` : "";
|
||||
const imageKeys: string[] = [];
|
||||
const mentionedOpenIds: string[] = [];
|
||||
|
||||
for (const paragraph of contentBlocks) {
|
||||
if (Array.isArray(paragraph)) {
|
||||
@@ -272,9 +263,6 @@ function parsePostContent(content: string): {
|
||||
} else if (element.tag === "at") {
|
||||
// Mention: @username
|
||||
textContent += `@${element.user_name || element.user_id || ""}`;
|
||||
if (element.user_id) {
|
||||
mentionedOpenIds.push(element.user_id);
|
||||
}
|
||||
} else if (element.tag === "img" && element.image_key) {
|
||||
// Embedded image
|
||||
imageKeys.push(element.image_key);
|
||||
@@ -285,12 +273,11 @@ function parsePostContent(content: string): {
|
||||
}
|
||||
|
||||
return {
|
||||
textContent: textContent.trim() || "[Rich text message]",
|
||||
textContent: textContent.trim() || "[富文本消息]",
|
||||
imageKeys,
|
||||
mentionedOpenIds,
|
||||
};
|
||||
} catch {
|
||||
return { textContent: "[Rich text message]", imageKeys: [], mentionedOpenIds: [] };
|
||||
return { textContent: "[富文本消息]", imageKeys: [] };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
|
||||
import {
|
||||
resolveFeishuAccount,
|
||||
resolveFeishuCredentials,
|
||||
@@ -23,7 +24,6 @@ import { resolveFeishuGroupToolPolicy } from "./policy.js";
|
||||
import { probeFeishu } from "./probe.js";
|
||||
import { sendMessageFeishu } from "./send.js";
|
||||
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
|
||||
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
|
||||
|
||||
const meta: ChannelMeta = {
|
||||
id: "feishu",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Readable } from "stream";
|
||||
import type * as Lark from "@larksuiteoapi/node-sdk";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { Readable } from "stream";
|
||||
import { listEnabledFeishuAccounts } from "./accounts.js";
|
||||
import { createFeishuClient } from "./client.js";
|
||||
import { FeishuDocSchema, type FeishuDocParams } from "./doc-schema.js";
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user