Compare commits

...

47 Commits

Author SHA1 Message Date
Dewaldt Huysamen 19923d0c22
Merge f90b98a02c into c4265a5f16 2026-03-15 22:43:07 +08:00
Ayaan Zaidi c4265a5f16
fix: preserve Telegram word boundaries when rechunking HTML (#47274)
* fix: preserve Telegram chunk word boundaries

* fix: address Telegram chunking review feedback

* fix: preserve Telegram retry separators

* fix: preserve Telegram chunking boundaries (#47274)
2026-03-15 18:10:49 +05:30
Andrew Demczuk 26e0a3ee9a
fix(gateway): skip Control UI pairing when auth.mode=none (closes #42931) (#47148)
When auth is completely disabled (mode=none), requiring device pairing
for Control UI operator sessions adds friction without security value
since any client can already connect without credentials.

Add authMode parameter to shouldSkipControlUiPairing so the bypass
fires only for Control UI + operator role + auth.mode=none. This avoids
the #43478 regression where a top-level OR disabled pairing for ALL
websocket clients.
2026-03-15 13:03:39 +01:00
助爪 5c5c64b612
Deduplicate repeated tool call IDs for OpenAI-compatible APIs (#40996)
Merged via squash.

Prepared head SHA: 38d8048359
Co-authored-by: xaeon2026 <264572156+xaeon2026@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-15 19:46:07 +08:00
Jason 9d3e653ec9
fix(web): handle 515 Stream Error during WhatsApp QR pairing (#27910)
* fix(web): handle 515 Stream Error during WhatsApp QR pairing

getStatusCode() never unwrapped the lastDisconnect wrapper object,
so login.errorStatus was always undefined and the 515 restart path
in restartLoginSocket was dead code.

- Add err.error?.output?.statusCode fallback to getStatusCode()
- Export waitForCredsSaveQueue() so callers can await pending creds
- Await creds flush in restartLoginSocket before creating new socket

Fixes #3942

* test: update session mock for getStatusCode unwrap + waitForCredsSaveQueue

Mirror the getStatusCode fix (err.error?.output?.statusCode fallback)
in the test mock and export waitForCredsSaveQueue so restartLoginSocket
tests work correctly.

* fix(web): scope creds save queue per-authDir to avoid cross-account blocking

The credential save queue was a single global promise chain shared by all
WhatsApp accounts. In multi-account setups, a slow save on one account
blocked credential writes and 515 restart recovery for unrelated accounts.

Replace the global queue with a per-authDir Map so each account's creds
serialize independently. waitForCredsSaveQueue() now accepts an optional
authDir to wait on a single account's queue, or waits on all when omitted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: use real Baileys v7 error shape in 515 restart test

The test was using { output: { statusCode: 515 } } which was already
handled before the fix. Updated to use the actual Baileys v7 shape
{ error: { output: { statusCode: 515 } } } to cover the new fallback
path in getStatusCode.

Co-Authored-By: Claude Code (Opus 4.6) <noreply@anthropic.com>

* fix(web): bound credential-queue wait during 515 restart

Prevents restartLoginSocket from blocking indefinitely if a queued
saveCreds() promise stalls (e.g. hung filesystem write).

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: clear flush timeout handle and assert creds queue in test

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: evict settled credsSaveQueues entries to prevent unbounded growth

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: share WhatsApp 515 creds flush handling (#27910) (thanks @asyncjason)

---------

Co-authored-by: Jason Separovic <jason@wilma.dog>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-15 17:00:07 +05:30
Ted Li 843e3c1efb
fix(whatsapp): restore append recency filter lost in extensions refactor, handle Long timestamps (#42588)
Merged via squash.

Prepared head SHA: 8ce59bb715
Co-authored-by: MonkeyLeeT <6754057+MonkeyLeeT@users.noreply.github.com>
Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com>
Reviewed-by: @scoootscooob
2026-03-15 03:03:31 -07:00
Ace Lee d7ac16788e
fix(android): support android node `calllog.search` (#44073)
* fix(android): support android node  `calllog.search`

* fix(android): support android node calllog.search

* fix(android): wire callLog through shared surfaces

* fix: land Android callLog support (#44073) (thanks @lxk7280)

---------

Co-authored-by: lixuankai <lixuankai@oppo.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-15 14:54:32 +05:30
Frank Yang 4bb8a65edd
fix: forward forceDocument through sendPayload path (follow-up to #45111) (#47119)
Merged via squash.

Prepared head SHA: d791190f83
Co-authored-by: thepagent <262003297+thepagent@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-15 17:23:53 +08:00
Sahan 9616d1e8ba
fix: Disable strict mode tools for non-native openai-completions compatible APIs (#45497)
Merged via squash.

Prepared head SHA: 20fe05fe74
Co-authored-by: sahancava <57447079+sahancava@users.noreply.github.com>
Co-authored-by: frankekn <4488090+frankekn@users.noreply.github.com>
Reviewed-by: @frankekn
2026-03-15 16:36:52 +08:00
Onur Solmaz a2d73be3a4
Docs: switch README logo to SVG assets (#47049) 2026-03-15 08:58:45 +01:00
SkunkWorks0x c33375f843
docs: replace outdated Clawdbot references with OpenClaw in skill docs (#41563)
Update 5 references to the old "Clawdbot" name in
skills/apple-reminders/SKILL.md and skills/imsg/SKILL.md.

Co-authored-by: imanisynapse <imanisynapse@gmail.com>
2026-03-15 08:29:19 +01:00
Praveen K Singh d230bd9c38
Docs: fix stale Clawdbot branding in agent workflow file (#46963)
Co-authored-by: webdevpraveen <webdevpraveen@users.noreply.github.com>
2026-03-15 08:01:03 +01:00
Ayaan Zaidi 6a458ef29e
fix: harden compaction timeout follow-ups 2026-03-15 12:13:23 +05:30
Jason f77a684131
feat: make compaction timeout configurable via agents.defaults.compaction.timeoutSeconds (#46889)
* feat: make compaction timeout configurable via agents.defaults.compaction.timeoutSeconds

The hardcoded 5-minute (300s) compaction timeout causes large sessions
to enter a death spiral where compaction repeatedly fails and the
session grows indefinitely. This adds agents.defaults.compaction.timeoutSeconds
to allow operators to override the compaction safety timeout.

Default raised to 900s (15min) which is sufficient for sessions up to
~400k tokens. The resolved timeout is also used for the session write
lock duration so locks don't expire before compaction completes.

Fixes #38233

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add resolveCompactionTimeoutMs tests

Cover config resolution edge cases: undefined config, missing
compaction section, valid seconds, fractional values, zero,
negative, NaN, and Infinity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add timeoutSeconds to compaction Zod schema

The compaction object schema uses .strict(), so setting the new
timeoutSeconds config option would fail validation at startup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: enforce integer constraint on compaction timeoutSeconds schema

Prevents sub-second values like 0.5 which would floor to 0ms and
cause immediate compaction timeout. Matches pattern of other
integer timeout fields in the schema.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: clamp compaction timeout to Node timer-safe maximum

Values above ~2.1B ms overflow Node's setTimeout to 1ms, causing
immediate timeout. Clamp to MAX_SAFE_TIMEOUT_MS matching the
pattern in agents/timeout.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add FIELD_LABELS entry for compaction timeoutSeconds

Maintains label/help parity invariant enforced by
schema.help.quality.test.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: align compaction timeouts with abort handling

* fix: land compaction timeout handling (#46889) (thanks @asyncjason)

---------

Co-authored-by: Jason Separovic <jason@wilma.dog>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-15 12:04:48 +05:30
Vincent Koc 8e04d1fe15
macOS: restrict canvas agent actions to trusted surfaces (#46790)
* macOS: restrict canvas agent actions to trusted surfaces

* Changelog: note trusted macOS canvas actions

* macOS: encode allowed canvas schemes as JSON
2026-03-14 23:26:19 -07:00
Vincent Koc 3cbf932413
Tlon: honor explicit empty allowlists and defer cite expansion (#46788)
* Tlon: fail closed on explicit empty allowlists

* Tlon: preserve cited content for owner DMs
2026-03-14 23:24:53 -07:00
Vincent Koc d1e4ee03ff fix(context): skip eager warmup for non-model CLI commands 2026-03-14 23:20:15 -07:00
Jinhao Dong 8e4a1d87e2
fix(openrouter): silently dropped images for new OpenRouter models — runtime capability detection (#45824)
* fix: fetch OpenRouter model capabilities at runtime for unknown models

When an OpenRouter model is not in the built-in static snapshot from
pi-ai, the fallback hardcodes input: ["text"], silently dropping images.

Query the OpenRouter API at runtime to detect actual capabilities
(image support, reasoning, context window) for models not in the
built-in list. Results are cached in memory for 1 hour. On API
failure/timeout, falls back to text-only (no regression).

* feat(openrouter): add disk cache for OpenRouter model capabilities

Persist the OpenRouter model catalog to ~/.openclaw/cache/openrouter-models.json
so it survives process restarts. Cache lookup order:

1. In-memory Map (instant)
2. On-disk JSON file (avoids network on restart)
3. OpenRouter API fetch (populates both layers)

Also triggers a background refresh when a model is not found in the cache,
in case it was newly added to OpenRouter.

* refactor(openrouter): remove pre-warm, use pure lazy-load with disk cache

- Remove eager ensureOpenRouterModelCache() from run.ts
- Remove TTL — model capabilities are stable, no periodic re-fetching
- Cache lookup: in-memory → disk → API fetch (only when needed)
- API is only called when no cache exists or a model is not found
- Disk cache persists across gateway restarts

* fix(openrouter): address review feedback

- Fix timer leak: move clearTimeout to finally block
- Fix modality check: only check input side of "->" separator to avoid
  matching image-generation models (text->image)
- Use resolveStateDir() instead of hardcoded homedir()/.openclaw
- Separate cache dir and filename constants
- Add utf-8 encoding to writeFileSync for consistency
- Add data validation when reading disk cache

* ci: retrigger checks

* fix: preload unknown OpenRouter model capabilities before resolve

* fix: accept top-level OpenRouter max token metadata

* fix: update changelog for OpenRouter runtime capability lookup (#45824) (thanks @DJjjjhao)

* fix: avoid redundant OpenRouter refetches and preserve suppression guards

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-15 11:48:39 +05:30
Vincent Koc a97b9014a2
External content: sanitize wrapped metadata (#46816) 2026-03-14 23:06:30 -07:00
Peter Steinberger 8851d06429
docs: reorder unreleased changelog 2026-03-14 22:16:41 -07:00
Ayaan Zaidi 37c79f84ba
fix(android): theme popup surfaces 2026-03-15 09:48:08 +05:30
Sebastian Schubotz db20141993
feat(android): add dark theme (#46249)
* Android: add mobile dark theme

* Android: fix remaining dark mode card surfaces

* Android: address dark mode review comments

* fix(android): theme onboarding flow

* fix: add Android dark theme coverage (#46249) (thanks @sibbl)

---------

Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-03-15 08:35:04 +05:30
Tak Hoffman 29fec8bb9f
fix(gateway): harden health monitor account gating (#46749)
* gateway: harden health monitor account gating

* gateway: tighten health monitor account-id guard
2026-03-14 21:58:28 -05:00
Vincent Koc 8aaafa045a
docker: add lsof to runtime image (#46636) 2026-03-14 19:40:29 -07:00
rstar327 ba6064cc22
feat(gateway): make health monitor stale threshold and max restarts configurable (openclaw#42107)
Verified:
- pnpm exec vitest --run src/config/config-misc.test.ts -t "gateway.channelHealthCheckMinutes"
- pnpm exec vitest --run src/gateway/server-channels.test.ts -t "health monitor"
- pnpm exec vitest --run src/gateway/channel-health-monitor.test.ts src/gateway/server/readiness.test.ts
- pnpm exec vitest --run extensions/feishu/src/outbound.test.ts
- pnpm exec tsc --noEmit

Co-authored-by: rstar327 <114364448+rstar327@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-14 21:21:56 -05:00
Tak Hoffman f00db91590
fix(plugins): prefer explicit installs over bundled duplicates (#46722)
* fix(plugins): prefer explicit installs over bundled duplicates

* test(feishu): mock structured card sends in outbound tests

* fix(plugins): align duplicate diagnostics with loader precedence
2026-03-14 21:08:32 -05:00
Radek Sienkiewicz e3b7ff2f1f
Docs: fix MDX markers blocking page refreshes (#46695)
Merged via squash.

Prepared head SHA: 56b25a9fb3
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-15 02:58:59 +01:00
songlei df3a247db2
feat(feishu): structured cards with identity header, note footer, and streaming enhancements (openclaw#29938)
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: nszhsl <512639+nszhsl@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-14 20:31:46 -05:00
Tak Hoffman f4dbd78afd
Add Feishu reactions and card action support (#46692)
* Add Feishu reactions and card action support

* Tighten Feishu action handling
2026-03-14 20:25:02 -05:00
Hiago Silva 946c24d674
fix: validate edge tts output file is non-empty before reporting success (#43385) thanks @Huntterxx
Merged after review.\n\nSmall, scoped fix: treat 0-byte Edge TTS output as failure so provider fallback can continue.
2026-03-14 20:22:09 -05:00
Tomsun28 c57b750be4
feat(provider): support new model zai glm-5-turbo, performs better for openclaw (openclaw#46670)
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: tomsun28 <24788200+tomsun28@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-14 20:19:41 -05:00
Radek Sienkiewicz 4c6a7f84a4
docs: remove dead security README nav entry (#46675)
Merged via squash.

Prepared head SHA: 63331a54b8
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Co-authored-by: velvet-shark <126378+velvet-shark@users.noreply.github.com>
Reviewed-by: @velvet-shark
2026-03-15 01:40:00 +01:00
Tak Hoffman 774b40467b
fix(zalouser): stop inheriting dm allowlist for groups (#46663) 2026-03-14 19:10:11 -05:00
nmccready f4aff83c51
feat(webchat): add toggle to hide tool calls and thinking blocks (#20317) thanks @nmccready
Merged via maintainer override after review.\n\nRed required checks are unrelated to this PR; local inspection found no blocker in the diff.
2026-03-14 19:03:04 -05:00
Tak Hoffman e5a42c0bec
fix(feishu): keep sender-scoped thread bootstrap across id types (#46651) 2026-03-14 18:47:05 -05:00
Andrew Demczuk 92fc8065e9
fix(gateway): remove re-introduced auth.mode=none pairing bypass
The revert of #43478 (commit 39b4185d0b) was silently undone by
3704293e6f which was based on a branch that included the original
change. This removes the auth.mode=none skipPairing condition again.

The blanket skip was too broad - it disabled pairing for ALL websocket
clients, not just Control UI behind reverse proxies.
2026-03-15 00:46:24 +01:00
Tomáš Dinh b5b589d99d
fix(zalo): use plugin-sdk export for webhook client IP resolution (openclaw#46549)
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: Tomáš Dinh <82420070+No898@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-14 18:37:56 -05:00
Gugu-sugar c1a0196826
Fix Codex CLI auth profile sync (#45353)
Merged via squash.

Prepared head SHA: e5432ec4e1
Co-authored-by: Gugu-sugar <201366873+Gugu-sugar@users.noreply.github.com>
Co-authored-by: grp06 <1573959+grp06@users.noreply.github.com>
Reviewed-by: @grp06
2026-03-14 16:36:09 -07:00
Andrew Demczuk b202ac2ad1
revert: restore supportsUsageInStreaming=false default for non-native endpoints
Reverts #46500. Breaks Ollama, LM Studio, TGI, LocalAI, Mistral API -
these backends reject stream_options with 400/422.

This reverts commit bb06dc7cc9.
2026-03-15 00:34:04 +01:00
George Zhang 2806f2b878
Heartbeat: add isolatedSession option for fresh session per heartbeat run (#46634)
Reuses the cron isolated session pattern (resolveCronSession with forceNew)
to give each heartbeat a fresh session with no prior conversation history.
Reduces per-heartbeat token cost from ~100K to ~2-5K tokens.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 16:28:01 -07:00
day253 9e8df16732
feat(feishu): add reasoning stream support to streaming cards (openclaw#46029)
Verified:
- pnpm build
- pnpm check
- pnpm test:macmini

Co-authored-by: day253 <9634619+day253@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-14 18:23:03 -05:00
ufhy 3928b4872a
fix: persist context-engine auto-compaction counts (#42629)
Merged via squash.

Prepared head SHA: df8f292039
Co-authored-by: uf-hy <41638541+uf-hy@users.noreply.github.com>
Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com>
Reviewed-by: @jalehman
2026-03-14 16:22:10 -07:00
Brian Qu 8a607d7553
fix(feishu): fetch thread context so AI can see bot replies in topic threads (#45254)
* fix(feishu): fetch thread context so AI can see bot replies in topic threads

When a user replies in a Feishu topic thread, the AI previously could only
see the quoted parent message but not the bot's own prior replies in the
thread. This made multi-turn conversations in threads feel broken.

- Add `threadId` (omt_xxx) to `FeishuMessageInfo` and `getMessageFeishu`
- Add `listFeishuThreadMessages()` using `container_id_type=thread` API
  to fetch all messages in a thread including bot replies
- In `handleFeishuMessage`, fetch ThreadStarterBody and ThreadHistoryBody
  for topic session modes and pass them to the AI context
- Reuse quoted message result when rootId === parentId to avoid redundant
  API calls; exclude root message from thread history to prevent duplication
- Fall back to inbound ctx.threadId when rootId is absent or API fails
- Fetch newest messages first (ByCreateTimeDesc + reverse) so long threads
  keep the most recent turns instead of the oldest

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(feishu): skip redundant thread context injection on subsequent turns

Only inject ThreadHistoryBody on the first turn of a thread session.
On subsequent turns the session already contains prior context, so
re-injecting thread history (and starter) would waste tokens.

The heuristic checks whether the current user has already sent a
non-root message in the thread — if so, the session has prior turns
and thread context injection is skipped entirely.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(feishu): handle thread_id-only events in prior-turn detection

When ctx.rootId is undefined (thread_id-only events), the starter
message exclusion check `msg.messageId !== ctx.rootId` was always
true, causing the first follow-up to be misclassified as a prior
turn. Fall back to the first message in the chronologically-sorted
thread history as the starter.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(feishu): bootstrap topic thread context via session state

* test(memory): pin remote embedding hostnames in offline suites

* fix(feishu): use plugin-safe session runtime for thread bootstrap

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-03-14 18:01:59 -05:00
George Zhang 3704293e6f
browser: drop headless/remote MCP attach modes, simplify existing-session to autoConnect-only (#46628) 2026-03-14 15:54:22 -07:00
GodsBoy f90b98a02c fix: cast AgentModelConfig in tests to resolve TS2339 on .primary 2026-03-07 07:29:40 +02:00
Dewaldt Huysamen c632308dcc fix: guard multiselect return against cancel (null/undefined) 2026-03-07 07:25:17 +02:00
Dewaldt Huysamen f6ea75ed9f feat(onboarding): add Memory Optimization step to onboarding wizard 2026-03-07 07:25:17 +02:00
213 changed files with 8398 additions and 1596 deletions

View File

@ -1,8 +1,8 @@
--- ---
description: Update Clawdbot from upstream when branch has diverged (ahead/behind) description: Update OpenClaw from upstream when branch has diverged (ahead/behind)
--- ---
# Clawdbot Upstream Sync Workflow # OpenClaw Upstream Sync Workflow
Use this workflow when your fork has diverged from upstream (e.g., "18 commits ahead, 29 commits behind"). Use this workflow when your fork has diverged from upstream (e.g., "18 commits ahead, 29 commits behind").
@ -132,16 +132,16 @@ pnpm mac:package
```bash ```bash
# Kill running app # Kill running app
pkill -x "Clawdbot" || true pkill -x "OpenClaw" || true
# Move old version # Move old version
mv /Applications/Clawdbot.app /tmp/Clawdbot-backup.app mv /Applications/OpenClaw.app /tmp/OpenClaw-backup.app
# Install new build # Install new build
cp -R dist/Clawdbot.app /Applications/ cp -R dist/OpenClaw.app /Applications/
# Launch # Launch
open /Applications/Clawdbot.app open /Applications/OpenClaw.app
``` ```
--- ---
@ -235,7 +235,7 @@ If upstream introduced new model configurations:
# Check for OpenRouter API key requirements # Check for OpenRouter API key requirements
grep -r "openrouter\|OPENROUTER" src/ --include="*.ts" --include="*.js" grep -r "openrouter\|OPENROUTER" src/ --include="*.ts" --include="*.js"
# Update clawdbot.json with fallback chains # Update openclaw.json with fallback chains
# Add model fallback configurations as needed # Add model fallback configurations as needed
``` ```

View File

@ -12314,14 +12314,14 @@
"filename": "src/config/schema.help.ts", "filename": "src/config/schema.help.ts",
"hashed_secret": "9f4cda226d3868676ac7f86f59e4190eb94bd208", "hashed_secret": "9f4cda226d3868676ac7f86f59e4190eb94bd208",
"is_verified": false, "is_verified": false,
"line_number": 653 "line_number": 657
}, },
{ {
"type": "Secret Keyword", "type": "Secret Keyword",
"filename": "src/config/schema.help.ts", "filename": "src/config/schema.help.ts",
"hashed_secret": "01822c8bbf6a8b136944b14182cb885100ec2eae", "hashed_secret": "01822c8bbf6a8b136944b14182cb885100ec2eae",
"is_verified": false, "is_verified": false,
"line_number": 686 "line_number": 690
} }
], ],
"src/config/schema.irc.ts": [ "src/config/schema.irc.ts": [
@ -12360,14 +12360,14 @@
"filename": "src/config/schema.labels.ts", "filename": "src/config/schema.labels.ts",
"hashed_secret": "e73c9fcad85cd4eecc74181ec4bdb31064d68439", "hashed_secret": "e73c9fcad85cd4eecc74181ec4bdb31064d68439",
"is_verified": false, "is_verified": false,
"line_number": 217 "line_number": 219
}, },
{ {
"type": "Secret Keyword", "type": "Secret Keyword",
"filename": "src/config/schema.labels.ts", "filename": "src/config/schema.labels.ts",
"hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff", "hashed_secret": "2eda7cd978f39eebec3bf03e4410a40e14167fff",
"is_verified": false, "is_verified": false,
"line_number": 326 "line_number": 328
} }
], ],
"src/config/slack-http-config.test.ts": [ "src/config/slack-http-config.test.ts": [

View File

@ -6,24 +6,45 @@ Docs: https://docs.openclaw.ai
### Changes ### Changes
- Android/mobile: add a system-aware dark theme across onboarding and post-onboarding screens so the app follows the device theme through setup, chat, and voice flows. (#46249) Thanks @sibbl.
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman. - Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.
- Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327.
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior.
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob. - Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
- Browser/existing-session: add headless Chrome DevTools MCP support for Linux, Docker, and VPS setups, including explicit browser URL and WebSocket endpoint attach modes for `existing-session`. Thanks @vincentkoc. - Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280.
### Fixes ### Fixes
- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969)
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong. - Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong.
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc.
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. Thanks @Coobiw.
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob. - Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc.
- Android/chat: theme the thinking dropdown and TLS trust dialogs explicitly so popup surfaces match the active app theme instead of falling back to mismatched Material defaults.
- Z.AI/onboarding: detect a working default model even for explicit `zai-coding-*` endpoint choices, so Coding Plan setup can keep the selected endpoint while defaulting to `glm-5` when available or `glm-4.7` as fallback. (#45969)
- Models/OpenRouter runtime capabilities: fetch uncatalogued OpenRouter model metadata on first use so newly added vision models keep image input instead of silently degrading to text-only, with top-level capability field fallbacks for `/api/v1/models`. (#45824) Thanks @DJjjjhao.
- Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28.
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. Thanks @vincentkoc.
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
- Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava.
- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) thanks @MonkeyLeeT.
- WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason.
- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026.
### Fixes ### Fixes
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc. - Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc.
- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898.
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob. - CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
- Agents/usage tracking: stop forcing `supportsUsageInStreaming: false` on non-native openai-completions endpoints so providers like DashScope, DeepSeek, and other OpenAI-compatible backends report token usage and cost instead of showing all zeros. (#46142)
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc.
- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#45777) Thanks @odysseus0. - Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#45777) Thanks @odysseus0.
- Docs/Mintlify: fix MDX marker syntax on Perplexity, Model Providers, Moonshot, and exec approvals pages so local docs preview no longer breaks rendering or leaves stale pages unpublished. (#46695) Thanks @velvet-shark.
- Email/webhook wrapping: sanitize sender and subject metadata before external-content wrapping so metadata fields cannot break the wrapper structure. Thanks @vincentkoc.
- Node/startup: remove leftover debug `console.log("node host PATH: ...")` that printed the resolved PATH on every `openclaw node run` invocation. (#46411)
- Telegram/message send: forward `--force-document` through the `sendPayload` path as well as `sendMedia`, so Telegram payload sends with `channelData` keep uploading images as documents instead of silently falling back to compressed photo sends. (#47119) Thanks @thepagent.
- Telegram/message chunking: preserve spaces, paragraph separators, and word boundaries when HTML overflow rechunking splits formatted replies. (#47274)
## 2026.3.13 ## 2026.3.13
@ -97,7 +118,10 @@ Docs: https://docs.openclaw.ai
- Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08. - Telegram/media errors: redact Telegram file URLs before building media fetch errors so failed inbound downloads do not leak bot tokens into logs. Thanks @space08.
- Agents/failover: normalize abort-wrapped `429 RESOURCE_EXHAUSTED` provider failures before abort short-circuiting so wrapped Google/Vertex rate limits continue across configured fallback models, including the embedded runner prompt-error path. (#39820) Thanks @lupuletic. - Agents/failover: normalize abort-wrapped `429 RESOURCE_EXHAUSTED` provider failures before abort short-circuiting so wrapped Google/Vertex rate limits continue across configured fallback models, including the embedded runner prompt-error path. (#39820) Thanks @lupuletic.
- Mattermost/thread routing: non-inbound reply paths (TUI/WebUI turns, tool-call callbacks, subagent responses) now correctly route to the originating Mattermost thread when `replyToMode: "all"` is active; also prevents stale `origin.threadId` metadata from resurrecting cleared thread routes. (#44283) thanks @teconomix - Mattermost/thread routing: non-inbound reply paths (TUI/WebUI turns, tool-call callbacks, subagent responses) now correctly route to the originating Mattermost thread when `replyToMode: "all"` is active; also prevents stale `origin.threadId` metadata from resurrecting cleared thread routes. (#44283) thanks @teconomix
- Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement when `gateway.auth.mode=none` so Control UI connections behind reverse proxies no longer get stuck on `pairing required` (code 1008) despite auth being explicitly disabled. (#42931)
- Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057) - Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057)
- Auto-reply/context-engine compaction: persist the exact embedded-run metadata compaction count for main and followup runner session accounting, so metadata-only auto-compactions no longer undercount multi-compaction runs. (#42629) thanks @uf-hy.
- Auth/Codex CLI reuse: sync reused Codex CLI credentials into the supported `openai-codex:default` OAuth profile instead of reviving the deprecated `openai-codex:codex-cli` slot, so doctor cleanup no longer loops. (#45353) thanks @Gugu-sugar.
## 2026.3.12 ## 2026.3.12

View File

@ -134,7 +134,7 @@ RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,shar
apt-get update && \ apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends && \ DEBIAN_FRONTEND=noninteractive apt-get upgrade -y --no-install-recommends && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
procps hostname curl git openssl procps hostname curl git lsof openssl
RUN chown node:node /app RUN chown node:node /app

View File

@ -2,8 +2,8 @@
<p align="center"> <p align="center">
<picture> <picture>
<source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.png"> <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text-dark.svg">
<img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.png" alt="OpenClaw" width="500"> <img src="https://raw.githubusercontent.com/openclaw/openclaw/main/docs/assets/openclaw-logo-text.svg" alt="OpenClaw" width="500">
</picture> </picture>
</p> </p>

View File

@ -19,6 +19,7 @@
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_CONTACTS" /> <uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" /> <uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.READ_CALENDAR" /> <uses-permission android:name="android.permission.READ_CALENDAR" />
<uses-permission android:name="android.permission.WRITE_CALENDAR" /> <uses-permission android:name="android.permission.WRITE_CALENDAR" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" /> <uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />

View File

@ -110,6 +110,10 @@ class NodeRuntime(context: Context) {
appContext = appContext, appContext = appContext,
) )
private val callLogHandler: CallLogHandler = CallLogHandler(
appContext = appContext,
)
private val motionHandler: MotionHandler = MotionHandler( private val motionHandler: MotionHandler = MotionHandler(
appContext = appContext, appContext = appContext,
) )
@ -151,6 +155,7 @@ class NodeRuntime(context: Context) {
smsHandler = smsHandlerImpl, smsHandler = smsHandlerImpl,
a2uiHandler = a2uiHandler, a2uiHandler = a2uiHandler,
debugHandler = debugHandler, debugHandler = debugHandler,
callLogHandler = callLogHandler,
isForeground = { _isForeground.value }, isForeground = { _isForeground.value },
cameraEnabled = { cameraEnabled.value }, cameraEnabled = { cameraEnabled.value },
locationEnabled = { locationMode.value != LocationMode.Off }, locationEnabled = { locationMode.value != LocationMode.Off },

View File

@ -0,0 +1,247 @@
package ai.openclaw.app.node
import android.Manifest
import android.content.Context
import android.provider.CallLog
import androidx.core.content.ContextCompat
import ai.openclaw.app.gateway.GatewaySession
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.put
private const val DEFAULT_CALL_LOG_LIMIT = 25
internal data class CallLogRecord(
val number: String?,
val cachedName: String?,
val date: Long,
val duration: Long,
val type: Int,
)
internal data class CallLogSearchRequest(
val limit: Int, // Number of records to return
val offset: Int, // Offset value
val cachedName: String?, // Search by contact name
val number: String?, // Search by phone number
val date: Long?, // Search by time (timestamp, deprecated, use dateStart/dateEnd)
val dateStart: Long?, // Query start time (timestamp)
val dateEnd: Long?, // Query end time (timestamp)
val duration: Long?, // Search by duration (seconds)
val type: Int?, // Search by call log type
)
internal interface CallLogDataSource {
fun hasReadPermission(context: Context): Boolean
fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord>
}
private object SystemCallLogDataSource : CallLogDataSource {
override fun hasReadPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_CALL_LOG
) == android.content.pm.PackageManager.PERMISSION_GRANTED
}
override fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord> {
val resolver = context.contentResolver
val projection = arrayOf(
CallLog.Calls.NUMBER,
CallLog.Calls.CACHED_NAME,
CallLog.Calls.DATE,
CallLog.Calls.DURATION,
CallLog.Calls.TYPE,
)
// Build selection and selectionArgs for filtering
val selections = mutableListOf<String>()
val selectionArgs = mutableListOf<String>()
request.cachedName?.let {
selections.add("${CallLog.Calls.CACHED_NAME} LIKE ?")
selectionArgs.add("%$it%")
}
request.number?.let {
selections.add("${CallLog.Calls.NUMBER} LIKE ?")
selectionArgs.add("%$it%")
}
// Support time range query
if (request.dateStart != null && request.dateEnd != null) {
selections.add("${CallLog.Calls.DATE} >= ? AND ${CallLog.Calls.DATE} <= ?")
selectionArgs.add(request.dateStart.toString())
selectionArgs.add(request.dateEnd.toString())
} else if (request.dateStart != null) {
selections.add("${CallLog.Calls.DATE} >= ?")
selectionArgs.add(request.dateStart.toString())
} else if (request.dateEnd != null) {
selections.add("${CallLog.Calls.DATE} <= ?")
selectionArgs.add(request.dateEnd.toString())
} else if (request.date != null) {
// Compatible with the old date parameter (exact match)
selections.add("${CallLog.Calls.DATE} = ?")
selectionArgs.add(request.date.toString())
}
request.duration?.let {
selections.add("${CallLog.Calls.DURATION} = ?")
selectionArgs.add(it.toString())
}
request.type?.let {
selections.add("${CallLog.Calls.TYPE} = ?")
selectionArgs.add(it.toString())
}
val selection = if (selections.isNotEmpty()) selections.joinToString(" AND ") else null
val selectionArgsArray = if (selectionArgs.isNotEmpty()) selectionArgs.toTypedArray() else null
val sortOrder = "${CallLog.Calls.DATE} DESC"
resolver.query(
CallLog.Calls.CONTENT_URI,
projection,
selection,
selectionArgsArray,
sortOrder,
).use { cursor ->
if (cursor == null) return emptyList()
val numberIndex = cursor.getColumnIndex(CallLog.Calls.NUMBER)
val cachedNameIndex = cursor.getColumnIndex(CallLog.Calls.CACHED_NAME)
val dateIndex = cursor.getColumnIndex(CallLog.Calls.DATE)
val durationIndex = cursor.getColumnIndex(CallLog.Calls.DURATION)
val typeIndex = cursor.getColumnIndex(CallLog.Calls.TYPE)
// Skip offset rows
if (request.offset > 0 && cursor.moveToPosition(request.offset - 1)) {
// Successfully moved to offset position
}
val out = mutableListOf<CallLogRecord>()
var count = 0
while (cursor.moveToNext() && count < request.limit) {
out += CallLogRecord(
number = cursor.getString(numberIndex),
cachedName = cursor.getString(cachedNameIndex),
date = cursor.getLong(dateIndex),
duration = cursor.getLong(durationIndex),
type = cursor.getInt(typeIndex),
)
count++
}
return out
}
}
}
class CallLogHandler private constructor(
private val appContext: Context,
private val dataSource: CallLogDataSource,
) {
constructor(appContext: Context) : this(appContext = appContext, dataSource = SystemCallLogDataSource)
fun handleCallLogSearch(paramsJson: String?): GatewaySession.InvokeResult {
if (!dataSource.hasReadPermission(appContext)) {
return GatewaySession.InvokeResult.error(
code = "CALL_LOG_PERMISSION_REQUIRED",
message = "CALL_LOG_PERMISSION_REQUIRED: grant Call Log permission",
)
}
val request = parseSearchRequest(paramsJson)
?: return GatewaySession.InvokeResult.error(
code = "INVALID_REQUEST",
message = "INVALID_REQUEST: expected JSON object",
)
return try {
val callLogs = dataSource.search(appContext, request)
GatewaySession.InvokeResult.ok(
buildJsonObject {
put(
"callLogs",
buildJsonArray {
callLogs.forEach { add(callLogJson(it)) }
},
)
}.toString(),
)
} catch (err: Throwable) {
GatewaySession.InvokeResult.error(
code = "CALL_LOG_UNAVAILABLE",
message = "CALL_LOG_UNAVAILABLE: ${err.message ?: "call log query failed"}",
)
}
}
private fun parseSearchRequest(paramsJson: String?): CallLogSearchRequest? {
if (paramsJson.isNullOrBlank()) {
return CallLogSearchRequest(
limit = DEFAULT_CALL_LOG_LIMIT,
offset = 0,
cachedName = null,
number = null,
date = null,
dateStart = null,
dateEnd = null,
duration = null,
type = null,
)
}
val params = try {
Json.parseToJsonElement(paramsJson).asObjectOrNull()
} catch (_: Throwable) {
null
} ?: return null
val limit = ((params["limit"] as? JsonPrimitive)?.content?.toIntOrNull() ?: DEFAULT_CALL_LOG_LIMIT)
.coerceIn(1, 200)
val offset = ((params["offset"] as? JsonPrimitive)?.content?.toIntOrNull() ?: 0)
.coerceAtLeast(0)
val cachedName = (params["cachedName"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
val number = (params["number"] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() }
val date = (params["date"] as? JsonPrimitive)?.content?.toLongOrNull()
val dateStart = (params["dateStart"] as? JsonPrimitive)?.content?.toLongOrNull()
val dateEnd = (params["dateEnd"] as? JsonPrimitive)?.content?.toLongOrNull()
val duration = (params["duration"] as? JsonPrimitive)?.content?.toLongOrNull()
val type = (params["type"] as? JsonPrimitive)?.content?.toIntOrNull()
return CallLogSearchRequest(
limit = limit,
offset = offset,
cachedName = cachedName,
number = number,
date = date,
dateStart = dateStart,
dateEnd = dateEnd,
duration = duration,
type = type,
)
}
private fun callLogJson(callLog: CallLogRecord): JsonObject {
return buildJsonObject {
put("number", JsonPrimitive(callLog.number))
put("cachedName", JsonPrimitive(callLog.cachedName))
put("date", JsonPrimitive(callLog.date))
put("duration", JsonPrimitive(callLog.duration))
put("type", JsonPrimitive(callLog.type))
}
}
companion object {
internal fun forTesting(
appContext: Context,
dataSource: CallLogDataSource,
): CallLogHandler = CallLogHandler(appContext = appContext, dataSource = dataSource)
}
}

View File

@ -212,6 +212,13 @@ class DeviceHandler(
promptableWhenDenied = true, promptableWhenDenied = true,
), ),
) )
put(
"callLog",
permissionStateJson(
granted = hasPermission(Manifest.permission.READ_CALL_LOG),
promptableWhenDenied = true,
),
)
put( put(
"motion", "motion",
permissionStateJson( permissionStateJson(

View File

@ -5,6 +5,7 @@ import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.app.protocol.OpenClawCanvasCommand import ai.openclaw.app.protocol.OpenClawCanvasCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCapability import ai.openclaw.app.protocol.OpenClawCapability
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawContactsCommand import ai.openclaw.app.protocol.OpenClawContactsCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand import ai.openclaw.app.protocol.OpenClawLocationCommand
@ -84,6 +85,7 @@ object InvokeCommandRegistry {
name = OpenClawCapability.Motion.rawValue, name = OpenClawCapability.Motion.rawValue,
availability = NodeCapabilityAvailability.MotionAvailable, availability = NodeCapabilityAvailability.MotionAvailable,
), ),
NodeCapabilitySpec(name = OpenClawCapability.CallLog.rawValue),
) )
val all: List<InvokeCommandSpec> = val all: List<InvokeCommandSpec> =
@ -187,6 +189,9 @@ object InvokeCommandRegistry {
name = OpenClawSmsCommand.Send.rawValue, name = OpenClawSmsCommand.Send.rawValue,
availability = InvokeCommandAvailability.SmsAvailable, availability = InvokeCommandAvailability.SmsAvailable,
), ),
InvokeCommandSpec(
name = OpenClawCallLogCommand.Search.rawValue,
),
InvokeCommandSpec( InvokeCommandSpec(
name = "debug.logs", name = "debug.logs",
availability = InvokeCommandAvailability.DebugBuild, availability = InvokeCommandAvailability.DebugBuild,

View File

@ -5,6 +5,7 @@ import ai.openclaw.app.protocol.OpenClawCalendarCommand
import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand import ai.openclaw.app.protocol.OpenClawCanvasA2UICommand
import ai.openclaw.app.protocol.OpenClawCanvasCommand import ai.openclaw.app.protocol.OpenClawCanvasCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawContactsCommand import ai.openclaw.app.protocol.OpenClawContactsCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand import ai.openclaw.app.protocol.OpenClawDeviceCommand
import ai.openclaw.app.protocol.OpenClawLocationCommand import ai.openclaw.app.protocol.OpenClawLocationCommand
@ -27,6 +28,7 @@ class InvokeDispatcher(
private val smsHandler: SmsHandler, private val smsHandler: SmsHandler,
private val a2uiHandler: A2UIHandler, private val a2uiHandler: A2UIHandler,
private val debugHandler: DebugHandler, private val debugHandler: DebugHandler,
private val callLogHandler: CallLogHandler,
private val isForeground: () -> Boolean, private val isForeground: () -> Boolean,
private val cameraEnabled: () -> Boolean, private val cameraEnabled: () -> Boolean,
private val locationEnabled: () -> Boolean, private val locationEnabled: () -> Boolean,
@ -161,6 +163,9 @@ class InvokeDispatcher(
// SMS command // SMS command
OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson) OpenClawSmsCommand.Send.rawValue -> smsHandler.handleSmsSend(paramsJson)
// CallLog command
OpenClawCallLogCommand.Search.rawValue -> callLogHandler.handleCallLogSearch(paramsJson)
// Debug commands // Debug commands
"debug.ed25519" -> debugHandler.handleEd25519() "debug.ed25519" -> debugHandler.handleEd25519()
"debug.logs" -> debugHandler.handleLogs() "debug.logs" -> debugHandler.handleLogs()

View File

@ -13,6 +13,7 @@ enum class OpenClawCapability(val rawValue: String) {
Contacts("contacts"), Contacts("contacts"),
Calendar("calendar"), Calendar("calendar"),
Motion("motion"), Motion("motion"),
CallLog("callLog"),
} }
enum class OpenClawCanvasCommand(val rawValue: String) { enum class OpenClawCanvasCommand(val rawValue: String) {
@ -137,3 +138,12 @@ enum class OpenClawMotionCommand(val rawValue: String) {
const val NamespacePrefix: String = "motion." const val NamespacePrefix: String = "motion."
} }
} }
enum class OpenClawCallLogCommand(val rawValue: String) {
Search("callLog.search"),
;
companion object {
const val NamespacePrefix: String = "callLog."
}
}

View File

@ -51,6 +51,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ai.openclaw.app.MainViewModel import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.mobileCardSurface
private enum class ConnectInputMode { private enum class ConnectInputMode {
SetupCode, SetupCode,
@ -91,20 +92,28 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
val prompt = pendingTrust!! val prompt = pendingTrust!!
AlertDialog( AlertDialog(
onDismissRequest = { viewModel.declineGatewayTrustPrompt() }, onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
title = { Text("Trust this gateway?") }, containerColor = mobileCardSurface,
title = { Text("Trust this gateway?", style = mobileHeadline, color = mobileText) },
text = { text = {
Text( Text(
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}", "First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
style = mobileCallout, style = mobileCallout,
color = mobileText,
) )
}, },
confirmButton = { confirmButton = {
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) { TextButton(
onClick = { viewModel.acceptGatewayTrustPrompt() },
colors = ButtonDefaults.textButtonColors(contentColor = mobileAccent),
) {
Text("Trust and continue") Text("Trust and continue")
} }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) { TextButton(
onClick = { viewModel.declineGatewayTrustPrompt() },
colors = ButtonDefaults.textButtonColors(contentColor = mobileTextSecondary),
) {
Text("Cancel") Text("Cancel")
} }
}, },
@ -144,7 +153,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
color = Color.White, color = mobileCardSurface,
border = BorderStroke(1.dp, mobileBorder), border = BorderStroke(1.dp, mobileBorder),
) { ) {
Column { Column {
@ -205,7 +214,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = colors =
ButtonDefaults.buttonColors( ButtonDefaults.buttonColors(
containerColor = Color.White, containerColor = mobileCardSurface,
contentColor = mobileDanger, contentColor = mobileDanger,
), ),
border = BorderStroke(1.dp, mobileDanger.copy(alpha = 0.4f)), border = BorderStroke(1.dp, mobileDanger.copy(alpha = 0.4f)),
@ -298,7 +307,7 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
color = Color.White, color = mobileCardSurface,
border = BorderStroke(1.dp, mobileBorder), border = BorderStroke(1.dp, mobileBorder),
) { ) {
Column( Column(
@ -480,7 +489,7 @@ private fun MethodChip(label: String, active: Boolean, onClick: () -> Unit) {
containerColor = if (active) mobileAccent else mobileSurface, containerColor = if (active) mobileAccent else mobileSurface,
contentColor = if (active) Color.White else mobileText, contentColor = if (active) Color.White else mobileText,
), ),
border = BorderStroke(1.dp, if (active) Color(0xFF184DAF) else mobileBorderStrong), border = BorderStroke(1.dp, if (active) mobileAccentBorderStrong else mobileBorderStrong),
) { ) {
Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.Bold)) Text(label, style = mobileCaption1.copy(fontWeight = FontWeight.Bold))
} }
@ -509,10 +518,10 @@ private fun CommandBlock(command: String) {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = mobileCodeBg, color = mobileCodeBg,
border = BorderStroke(1.dp, Color(0xFF2B2E35)), border = BorderStroke(1.dp, mobileCodeBorder),
) { ) {
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.width(3.dp).height(42.dp).background(Color(0xFF3FC97A))) Box(modifier = Modifier.width(3.dp).height(42.dp).background(mobileCodeAccent))
Text( Text(
text = command, text = command,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp),

View File

@ -1,5 +1,7 @@
package ai.openclaw.app.ui package ai.openclaw.app.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
@ -9,32 +11,147 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import ai.openclaw.app.R import ai.openclaw.app.R
internal val mobileBackgroundGradient = // ---------------------------------------------------------------------------
Brush.verticalGradient( // MobileColors semantic color tokens with light + dark variants
listOf( // ---------------------------------------------------------------------------
Color(0xFFFFFFFF),
Color(0xFFF7F8FA), internal data class MobileColors(
Color(0xFFEFF1F5), val surface: Color,
), val surfaceStrong: Color,
val cardSurface: Color,
val border: Color,
val borderStrong: Color,
val text: Color,
val textSecondary: Color,
val textTertiary: Color,
val accent: Color,
val accentSoft: Color,
val accentBorderStrong: Color,
val success: Color,
val successSoft: Color,
val warning: Color,
val warningSoft: Color,
val danger: Color,
val dangerSoft: Color,
val codeBg: Color,
val codeText: Color,
val codeBorder: Color,
val codeAccent: Color,
val chipBorderConnected: Color,
val chipBorderConnecting: Color,
val chipBorderWarning: Color,
val chipBorderError: Color,
) )
internal val mobileSurface = Color(0xFFF6F7FA) internal fun lightMobileColors() =
internal val mobileSurfaceStrong = Color(0xFFECEEF3) MobileColors(
internal val mobileBorder = Color(0xFFE5E7EC) surface = Color(0xFFF6F7FA),
internal val mobileBorderStrong = Color(0xFFD6DAE2) surfaceStrong = Color(0xFFECEEF3),
internal val mobileText = Color(0xFF17181C) cardSurface = Color(0xFFFFFFFF),
internal val mobileTextSecondary = Color(0xFF5D6472) border = Color(0xFFE5E7EC),
internal val mobileTextTertiary = Color(0xFF99A0AE) borderStrong = Color(0xFFD6DAE2),
internal val mobileAccent = Color(0xFF1D5DD8) text = Color(0xFF17181C),
internal val mobileAccentSoft = Color(0xFFECF3FF) textSecondary = Color(0xFF5D6472),
internal val mobileSuccess = Color(0xFF2F8C5A) textTertiary = Color(0xFF99A0AE),
internal val mobileSuccessSoft = Color(0xFFEEF9F3) accent = Color(0xFF1D5DD8),
internal val mobileWarning = Color(0xFFC8841A) accentSoft = Color(0xFFECF3FF),
internal val mobileWarningSoft = Color(0xFFFFF8EC) accentBorderStrong = Color(0xFF184DAF),
internal val mobileDanger = Color(0xFFD04B4B) success = Color(0xFF2F8C5A),
internal val mobileDangerSoft = Color(0xFFFFF2F2) successSoft = Color(0xFFEEF9F3),
internal val mobileCodeBg = Color(0xFF15171B) warning = Color(0xFFC8841A),
internal val mobileCodeText = Color(0xFFE8EAEE) warningSoft = Color(0xFFFFF8EC),
danger = Color(0xFFD04B4B),
dangerSoft = Color(0xFFFFF2F2),
codeBg = Color(0xFF15171B),
codeText = Color(0xFFE8EAEE),
codeBorder = Color(0xFF2B2E35),
codeAccent = Color(0xFF3FC97A),
chipBorderConnected = Color(0xFFCFEBD8),
chipBorderConnecting = Color(0xFFD5E2FA),
chipBorderWarning = Color(0xFFEED8B8),
chipBorderError = Color(0xFFF3C8C8),
)
internal fun darkMobileColors() =
MobileColors(
surface = Color(0xFF1A1C20),
surfaceStrong = Color(0xFF24262B),
cardSurface = Color(0xFF1E2024),
border = Color(0xFF2E3038),
borderStrong = Color(0xFF3A3D46),
text = Color(0xFFE4E5EA),
textSecondary = Color(0xFFA0A6B4),
textTertiary = Color(0xFF6B7280),
accent = Color(0xFF6EA8FF),
accentSoft = Color(0xFF1A2A44),
accentBorderStrong = Color(0xFF5B93E8),
success = Color(0xFF5FBB85),
successSoft = Color(0xFF152E22),
warning = Color(0xFFE8A844),
warningSoft = Color(0xFF2E2212),
danger = Color(0xFFE87070),
dangerSoft = Color(0xFF2E1616),
codeBg = Color(0xFF111317),
codeText = Color(0xFFE8EAEE),
codeBorder = Color(0xFF2B2E35),
codeAccent = Color(0xFF3FC97A),
chipBorderConnected = Color(0xFF1E4A30),
chipBorderConnecting = Color(0xFF1E3358),
chipBorderWarning = Color(0xFF3E3018),
chipBorderError = Color(0xFF3E1E1E),
)
internal val LocalMobileColors = staticCompositionLocalOf { lightMobileColors() }
internal object MobileColorsAccessor {
val current: MobileColors
@Composable get() = LocalMobileColors.current
}
// ---------------------------------------------------------------------------
// Backward-compatible top-level accessors (composable getters)
// ---------------------------------------------------------------------------
// These allow existing call sites to keep using `mobileSurface`, `mobileText`, etc.
// without converting every file at once. Each resolves to the themed value.
internal val mobileSurface: Color @Composable get() = LocalMobileColors.current.surface
internal val mobileSurfaceStrong: Color @Composable get() = LocalMobileColors.current.surfaceStrong
internal val mobileCardSurface: Color @Composable get() = LocalMobileColors.current.cardSurface
internal val mobileBorder: Color @Composable get() = LocalMobileColors.current.border
internal val mobileBorderStrong: Color @Composable get() = LocalMobileColors.current.borderStrong
internal val mobileText: Color @Composable get() = LocalMobileColors.current.text
internal val mobileTextSecondary: Color @Composable get() = LocalMobileColors.current.textSecondary
internal val mobileTextTertiary: Color @Composable get() = LocalMobileColors.current.textTertiary
internal val mobileAccent: Color @Composable get() = LocalMobileColors.current.accent
internal val mobileAccentSoft: Color @Composable get() = LocalMobileColors.current.accentSoft
internal val mobileAccentBorderStrong: Color @Composable get() = LocalMobileColors.current.accentBorderStrong
internal val mobileSuccess: Color @Composable get() = LocalMobileColors.current.success
internal val mobileSuccessSoft: Color @Composable get() = LocalMobileColors.current.successSoft
internal val mobileWarning: Color @Composable get() = LocalMobileColors.current.warning
internal val mobileWarningSoft: Color @Composable get() = LocalMobileColors.current.warningSoft
internal val mobileDanger: Color @Composable get() = LocalMobileColors.current.danger
internal val mobileDangerSoft: Color @Composable get() = LocalMobileColors.current.dangerSoft
internal val mobileCodeBg: Color @Composable get() = LocalMobileColors.current.codeBg
internal val mobileCodeText: Color @Composable get() = LocalMobileColors.current.codeText
internal val mobileCodeBorder: Color @Composable get() = LocalMobileColors.current.codeBorder
internal val mobileCodeAccent: Color @Composable get() = LocalMobileColors.current.codeAccent
// Background gradient light fades white→gray, dark fades near-black→dark-gray
internal val mobileBackgroundGradient: Brush
@Composable get() {
val colors = LocalMobileColors.current
return Brush.verticalGradient(
listOf(
colors.surface,
colors.surfaceStrong,
colors.surfaceStrong,
),
)
}
// ---------------------------------------------------------------------------
// Typography tokens (theme-independent)
// ---------------------------------------------------------------------------
internal val mobileFontFamily = internal val mobileFontFamily =
FontFamily( FontFamily(
@ -44,6 +161,15 @@ internal val mobileFontFamily =
Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold), Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold),
) )
internal val mobileDisplay =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 34.sp,
lineHeight = 40.sp,
letterSpacing = (-0.8).sp,
)
internal val mobileTitle1 = internal val mobileTitle1 =
TextStyle( TextStyle(
fontFamily = mobileFontFamily, fontFamily = mobileFontFamily,

View File

@ -81,7 +81,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
@ -94,7 +93,6 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.LocalLifecycleOwner
import ai.openclaw.app.LocationMode import ai.openclaw.app.LocationMode
import ai.openclaw.app.MainViewModel import ai.openclaw.app.MainViewModel
import ai.openclaw.app.R
import ai.openclaw.app.node.DeviceNotificationListenerService import ai.openclaw.app.node.DeviceNotificationListenerService
import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
@ -123,101 +121,87 @@ private enum class PermissionToggle {
Calendar, Calendar,
Motion, Motion,
Sms, Sms,
CallLog,
} }
private enum class SpecialAccessToggle { private enum class SpecialAccessToggle {
NotificationListener, NotificationListener,
} }
private val onboardingBackgroundGradient = private val onboardingBackgroundGradient: Brush
listOf( @Composable get() = mobileBackgroundGradient
Color(0xFFFFFFFF),
Color(0xFFF7F8FA),
Color(0xFFEFF1F5),
)
private val onboardingSurface = Color(0xFFF6F7FA)
private val onboardingBorder = Color(0xFFE5E7EC)
private val onboardingBorderStrong = Color(0xFFD6DAE2)
private val onboardingText = Color(0xFF17181C)
private val onboardingTextSecondary = Color(0xFF4D5563)
private val onboardingTextTertiary = Color(0xFF8A92A2)
private val onboardingAccent = Color(0xFF1D5DD8)
private val onboardingAccentSoft = Color(0xFFECF3FF)
private val onboardingSuccess = Color(0xFF2F8C5A)
private val onboardingWarning = Color(0xFFC8841A)
private val onboardingCommandBg = Color(0xFF15171B)
private val onboardingCommandBorder = Color(0xFF2B2E35)
private val onboardingCommandAccent = Color(0xFF3FC97A)
private val onboardingCommandText = Color(0xFFE8EAEE)
private val onboardingFontFamily = private val onboardingSurface: Color
FontFamily( @Composable get() = mobileCardSurface
Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal),
Font(resId = R.font.manrope_500_medium, weight = FontWeight.Medium),
Font(resId = R.font.manrope_600_semibold, weight = FontWeight.SemiBold),
Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold),
)
private val onboardingDisplayStyle = private val onboardingBorder: Color
TextStyle( @Composable get() = mobileBorder
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.Bold,
fontSize = 34.sp,
lineHeight = 40.sp,
letterSpacing = (-0.8).sp,
)
private val onboardingTitle1Style = private val onboardingBorderStrong: Color
TextStyle( @Composable get() = mobileBorderStrong
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 30.sp,
letterSpacing = (-0.5).sp,
)
private val onboardingHeadlineStyle = private val onboardingText: Color
TextStyle( @Composable get() = mobileText
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 22.sp,
letterSpacing = (-0.1).sp,
)
private val onboardingBodyStyle = private val onboardingTextSecondary: Color
TextStyle( @Composable get() = mobileTextSecondary
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
lineHeight = 22.sp,
)
private val onboardingCalloutStyle = private val onboardingTextTertiary: Color
TextStyle( @Composable get() = mobileTextTertiary
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
)
private val onboardingCaption1Style = private val onboardingAccent: Color
TextStyle( @Composable get() = mobileAccent
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.2.sp,
)
private val onboardingCaption2Style = private val onboardingAccentSoft: Color
TextStyle( @Composable get() = mobileAccentSoft
fontFamily = onboardingFontFamily,
fontWeight = FontWeight.Medium, private val onboardingAccentBorderStrong: Color
fontSize = 11.sp, @Composable get() = mobileAccentBorderStrong
lineHeight = 14.sp,
letterSpacing = 0.4.sp, private val onboardingSuccess: Color
) @Composable get() = mobileSuccess
private val onboardingSuccessSoft: Color
@Composable get() = mobileSuccessSoft
private val onboardingWarning: Color
@Composable get() = mobileWarning
private val onboardingWarningSoft: Color
@Composable get() = mobileWarningSoft
private val onboardingCommandBg: Color
@Composable get() = mobileCodeBg
private val onboardingCommandBorder: Color
@Composable get() = mobileCodeBorder
private val onboardingCommandAccent: Color
@Composable get() = mobileCodeAccent
private val onboardingCommandText: Color
@Composable get() = mobileCodeText
private val onboardingDisplayStyle: TextStyle
get() = mobileDisplay
private val onboardingTitle1Style: TextStyle
get() = mobileTitle1
private val onboardingHeadlineStyle: TextStyle
get() = mobileHeadline
private val onboardingBodyStyle: TextStyle
get() = mobileBody
private val onboardingCalloutStyle: TextStyle
get() = mobileCallout
private val onboardingCaption1Style: TextStyle
get() = mobileCaption1
private val onboardingCaption2Style: TextStyle
get() = mobileCaption2
@Composable @Composable
fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) { fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
@ -305,6 +289,10 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
rememberSaveable { rememberSaveable {
mutableStateOf(smsAvailable && isPermissionGranted(context, Manifest.permission.SEND_SMS)) mutableStateOf(smsAvailable && isPermissionGranted(context, Manifest.permission.SEND_SMS))
} }
var enableCallLog by
rememberSaveable {
mutableStateOf(isPermissionGranted(context, Manifest.permission.READ_CALL_LOG))
}
var pendingPermissionToggle by remember { mutableStateOf<PermissionToggle?>(null) } var pendingPermissionToggle by remember { mutableStateOf<PermissionToggle?>(null) }
var pendingSpecialAccessToggle by remember { mutableStateOf<SpecialAccessToggle?>(null) } var pendingSpecialAccessToggle by remember { mutableStateOf<SpecialAccessToggle?>(null) }
@ -321,6 +309,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
PermissionToggle.Calendar -> enableCalendar = enabled PermissionToggle.Calendar -> enableCalendar = enabled
PermissionToggle.Motion -> enableMotion = enabled && motionAvailable PermissionToggle.Motion -> enableMotion = enabled && motionAvailable
PermissionToggle.Sms -> enableSms = enabled && smsAvailable PermissionToggle.Sms -> enableSms = enabled && smsAvailable
PermissionToggle.CallLog -> enableCallLog = enabled
} }
} }
@ -348,6 +337,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION) isPermissionGranted(context, Manifest.permission.ACTIVITY_RECOGNITION)
PermissionToggle.Sms -> PermissionToggle.Sms ->
!smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS) !smsAvailable || isPermissionGranted(context, Manifest.permission.SEND_SMS)
PermissionToggle.CallLog -> isPermissionGranted(context, Manifest.permission.READ_CALL_LOG)
} }
fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) { fun setSpecialAccessToggleEnabled(toggle: SpecialAccessToggle, enabled: Boolean) {
@ -369,6 +359,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
enableCalendar, enableCalendar,
enableMotion, enableMotion,
enableSms, enableSms,
enableCallLog,
smsAvailable, smsAvailable,
motionAvailable, motionAvailable,
) { ) {
@ -384,6 +375,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
if (enableCalendar) enabled += "Calendar" if (enableCalendar) enabled += "Calendar"
if (enableMotion && motionAvailable) enabled += "Motion" if (enableMotion && motionAvailable) enabled += "Motion"
if (smsAvailable && enableSms) enabled += "SMS" if (smsAvailable && enableSms) enabled += "SMS"
if (enableCallLog) enabled += "Call Log"
if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ") if (enabled.isEmpty()) "None selected" else enabled.joinToString(", ")
} }
@ -472,19 +464,28 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
val prompt = pendingTrust!! val prompt = pendingTrust!!
AlertDialog( AlertDialog(
onDismissRequest = { viewModel.declineGatewayTrustPrompt() }, onDismissRequest = { viewModel.declineGatewayTrustPrompt() },
title = { Text("Trust this gateway?") }, containerColor = onboardingSurface,
title = { Text("Trust this gateway?", style = onboardingHeadlineStyle, color = onboardingText) },
text = { text = {
Text( Text(
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}", "First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
style = onboardingCalloutStyle,
color = onboardingText,
) )
}, },
confirmButton = { confirmButton = {
TextButton(onClick = { viewModel.acceptGatewayTrustPrompt() }) { TextButton(
onClick = { viewModel.acceptGatewayTrustPrompt() },
colors = ButtonDefaults.textButtonColors(contentColor = onboardingAccent),
) {
Text("Trust and continue") Text("Trust and continue")
} }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { viewModel.declineGatewayTrustPrompt() }) { TextButton(
onClick = { viewModel.declineGatewayTrustPrompt() },
colors = ButtonDefaults.textButtonColors(contentColor = onboardingTextSecondary),
) {
Text("Cancel") Text("Cancel")
} }
}, },
@ -495,7 +496,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
modifier = modifier =
modifier modifier
.fillMaxSize() .fillMaxSize()
.background(Brush.verticalGradient(onboardingBackgroundGradient)), .background(onboardingBackgroundGradient),
) { ) {
Column( Column(
modifier = modifier =
@ -603,6 +604,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
motionPermissionRequired = motionPermissionRequired, motionPermissionRequired = motionPermissionRequired,
enableSms = enableSms, enableSms = enableSms,
smsAvailable = smsAvailable, smsAvailable = smsAvailable,
enableCallLog = enableCallLog,
context = context, context = context,
onDiscoveryChange = { checked -> onDiscoveryChange = { checked ->
requestPermissionToggle( requestPermissionToggle(
@ -700,6 +702,13 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
) )
} }
}, },
onCallLogChange = { checked ->
requestPermissionToggle(
PermissionToggle.CallLog,
checked,
listOf(Manifest.permission.READ_CALL_LOG),
)
},
) )
OnboardingStep.FinalCheck -> OnboardingStep.FinalCheck ->
FinalStep( FinalStep(
@ -755,13 +764,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
onClick = { step = OnboardingStep.Gateway }, onClick = { step = OnboardingStep.Gateway },
modifier = Modifier.weight(1f).height(52.dp), modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = colors = onboardingPrimaryButtonColors(),
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White,
),
) { ) {
Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
} }
@ -807,13 +810,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}, },
modifier = Modifier.weight(1f).height(52.dp), modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = colors = onboardingPrimaryButtonColors(),
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White,
),
) { ) {
Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
} }
@ -827,13 +824,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}, },
modifier = Modifier.weight(1f).height(52.dp), modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = colors = onboardingPrimaryButtonColors(),
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White,
),
) { ) {
Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) Text("Next", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
} }
@ -844,13 +835,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
onClick = { viewModel.setOnboardingCompleted(true) }, onClick = { viewModel.setOnboardingCompleted(true) },
modifier = Modifier.weight(1f).height(52.dp), modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = colors = onboardingPrimaryButtonColors(),
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White,
),
) { ) {
Text("Finish", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) Text("Finish", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
} }
@ -883,13 +868,7 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
}, },
modifier = Modifier.weight(1f).height(52.dp), modifier = Modifier.weight(1f).height(52.dp),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = colors = onboardingPrimaryButtonColors(),
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White,
),
) { ) {
Text("Connect", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) Text("Connect", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
} }
@ -901,6 +880,36 @@ fun OnboardingFlow(viewModel: MainViewModel, modifier: Modifier = Modifier) {
} }
} }
@Composable
private fun onboardingPrimaryButtonColors() =
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
disabledContainerColor = onboardingAccent.copy(alpha = 0.45f),
disabledContentColor = Color.White.copy(alpha = 0.9f),
)
@Composable
private fun onboardingTextFieldColors() =
OutlinedTextFieldDefaults.colors(
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
)
@Composable
private fun onboardingSwitchColors() =
SwitchDefaults.colors(
checkedTrackColor = onboardingAccent,
uncheckedTrackColor = onboardingBorderStrong,
checkedThumbColor = Color.White,
uncheckedThumbColor = Color.White,
)
@Composable @Composable
private fun StepRail(current: OnboardingStep) { private fun StepRail(current: OnboardingStep) {
val steps = OnboardingStep.entries val steps = OnboardingStep.entries
@ -1005,11 +1014,7 @@ private fun GatewayStep(
onClick = onScanQrClick, onClick = onScanQrClick,
modifier = Modifier.fillMaxWidth().height(48.dp), modifier = Modifier.fillMaxWidth().height(48.dp),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
colors = colors = onboardingPrimaryButtonColors(),
ButtonDefaults.buttonColors(
containerColor = onboardingAccent,
contentColor = Color.White,
),
) { ) {
Text("Scan QR code", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold)) Text("Scan QR code", style = onboardingHeadlineStyle.copy(fontWeight = FontWeight.Bold))
} }
@ -1059,15 +1064,7 @@ private fun GatewayStep(
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText), textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = colors =
OutlinedTextFieldDefaults.colors( onboardingTextFieldColors(),
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
) )
if (!resolvedEndpoint.isNullOrBlank()) { if (!resolvedEndpoint.isNullOrBlank()) {
ResolvedEndpoint(endpoint = resolvedEndpoint) ResolvedEndpoint(endpoint = resolvedEndpoint)
@ -1097,15 +1094,7 @@ private fun GatewayStep(
textStyle = onboardingBodyStyle.copy(color = onboardingText), textStyle = onboardingBodyStyle.copy(color = onboardingText),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = colors =
OutlinedTextFieldDefaults.colors( onboardingTextFieldColors(),
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
) )
Text("PORT", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) Text("PORT", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
@ -1119,15 +1108,7 @@ private fun GatewayStep(
textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText), textStyle = onboardingBodyStyle.copy(fontFamily = FontFamily.Monospace, color = onboardingText),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = colors =
OutlinedTextFieldDefaults.colors( onboardingTextFieldColors(),
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
) )
Row( Row(
@ -1143,12 +1124,7 @@ private fun GatewayStep(
checked = manualTls, checked = manualTls,
onCheckedChange = onManualTlsChange, onCheckedChange = onManualTlsChange,
colors = colors =
SwitchDefaults.colors( onboardingSwitchColors(),
checkedTrackColor = onboardingAccent,
uncheckedTrackColor = onboardingBorderStrong,
checkedThumbColor = Color.White,
uncheckedThumbColor = Color.White,
),
) )
} }
@ -1163,15 +1139,7 @@ private fun GatewayStep(
textStyle = onboardingBodyStyle.copy(color = onboardingText), textStyle = onboardingBodyStyle.copy(color = onboardingText),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = colors =
OutlinedTextFieldDefaults.colors( onboardingTextFieldColors(),
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
) )
Text("PASSWORD (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary) Text("PASSWORD (OPTIONAL)", style = onboardingCaption1Style.copy(letterSpacing = 0.9.sp), color = onboardingTextSecondary)
@ -1185,15 +1153,7 @@ private fun GatewayStep(
textStyle = onboardingBodyStyle.copy(color = onboardingText), textStyle = onboardingBodyStyle.copy(color = onboardingText),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = colors =
OutlinedTextFieldDefaults.colors( onboardingTextFieldColors(),
focusedContainerColor = onboardingSurface,
unfocusedContainerColor = onboardingSurface,
focusedBorderColor = onboardingAccent,
unfocusedBorderColor = onboardingBorder,
focusedTextColor = onboardingText,
unfocusedTextColor = onboardingText,
cursorColor = onboardingAccent,
),
) )
if (!manualResolvedEndpoint.isNullOrBlank()) { if (!manualResolvedEndpoint.isNullOrBlank()) {
@ -1261,7 +1221,7 @@ private fun GatewayModeChip(
containerColor = if (active) onboardingAccent else onboardingSurface, containerColor = if (active) onboardingAccent else onboardingSurface,
contentColor = if (active) Color.White else onboardingText, contentColor = if (active) Color.White else onboardingText,
), ),
border = androidx.compose.foundation.BorderStroke(1.dp, if (active) Color(0xFF184DAF) else onboardingBorderStrong), border = androidx.compose.foundation.BorderStroke(1.dp, if (active) onboardingAccentBorderStrong else onboardingBorderStrong),
) { ) {
Text( Text(
text = label, text = label,
@ -1339,6 +1299,7 @@ private fun PermissionsStep(
motionPermissionRequired: Boolean, motionPermissionRequired: Boolean,
enableSms: Boolean, enableSms: Boolean,
smsAvailable: Boolean, smsAvailable: Boolean,
enableCallLog: Boolean,
context: Context, context: Context,
onDiscoveryChange: (Boolean) -> Unit, onDiscoveryChange: (Boolean) -> Unit,
onLocationChange: (Boolean) -> Unit, onLocationChange: (Boolean) -> Unit,
@ -1351,6 +1312,7 @@ private fun PermissionsStep(
onCalendarChange: (Boolean) -> Unit, onCalendarChange: (Boolean) -> Unit,
onMotionChange: (Boolean) -> Unit, onMotionChange: (Boolean) -> Unit,
onSmsChange: (Boolean) -> Unit, onSmsChange: (Boolean) -> Unit,
onCallLogChange: (Boolean) -> Unit,
) { ) {
val discoveryPermission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION val discoveryPermission = if (Build.VERSION.SDK_INT >= 33) Manifest.permission.NEARBY_WIFI_DEVICES else Manifest.permission.ACCESS_FINE_LOCATION
val locationGranted = val locationGranted =
@ -1481,6 +1443,15 @@ private fun PermissionsStep(
onCheckedChange = onSmsChange, onCheckedChange = onSmsChange,
) )
} }
InlineDivider()
PermissionToggleRow(
title = "Call Log",
subtitle = "callLog.search",
checked = enableCallLog,
granted = isPermissionGranted(context, Manifest.permission.READ_CALL_LOG),
onCheckedChange = onCallLogChange,
)
Text("All settings can be changed later in Settings.", style = onboardingCalloutStyle, color = onboardingTextSecondary)
} }
} }
@ -1524,13 +1495,7 @@ private fun PermissionToggleRow(
checked = checked, checked = checked,
onCheckedChange = onCheckedChange, onCheckedChange = onCheckedChange,
enabled = enabled, enabled = enabled,
colors = colors = onboardingSwitchColors(),
SwitchDefaults.colors(
checkedTrackColor = onboardingAccent,
uncheckedTrackColor = onboardingBorderStrong,
checkedThumbColor = Color.White,
uncheckedThumbColor = Color.White,
),
) )
} }
} }
@ -1605,7 +1570,7 @@ private fun FinalStep(
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
color = Color(0xFFEEF9F3), color = onboardingSuccessSoft,
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingSuccess.copy(alpha = 0.2f)), border = androidx.compose.foundation.BorderStroke(1.dp, onboardingSuccess.copy(alpha = 0.2f)),
) { ) {
Row( Row(
@ -1641,7 +1606,7 @@ private fun FinalStep(
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
color = Color(0xFFFFF8EC), color = onboardingWarningSoft,
border = androidx.compose.foundation.BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)), border = androidx.compose.foundation.BorderStroke(1.dp, onboardingWarning.copy(alpha = 0.2f)),
) { ) {
Column( Column(

View File

@ -5,6 +5,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@ -13,9 +14,12 @@ fun OpenClawTheme(content: @Composable () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val isDark = isSystemInDarkTheme() val isDark = isSystemInDarkTheme()
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
val mobileColors = if (isDark) darkMobileColors() else lightMobileColors()
CompositionLocalProvider(LocalMobileColors provides mobileColors) {
MaterialTheme(colorScheme = colorScheme, content = content) MaterialTheme(colorScheme = colorScheme, content = content)
} }
}
@Composable @Composable
fun overlayContainerColor(): Color { fun overlayContainerColor(): Color {

View File

@ -159,28 +159,28 @@ private fun TopStatusBar(
mobileSuccessSoft, mobileSuccessSoft,
mobileSuccess, mobileSuccess,
mobileSuccess, mobileSuccess,
Color(0xFFCFEBD8), LocalMobileColors.current.chipBorderConnected,
) )
StatusVisual.Connecting -> StatusVisual.Connecting ->
listOf( listOf(
mobileAccentSoft, mobileAccentSoft,
mobileAccent, mobileAccent,
mobileAccent, mobileAccent,
Color(0xFFD5E2FA), LocalMobileColors.current.chipBorderConnecting,
) )
StatusVisual.Warning -> StatusVisual.Warning ->
listOf( listOf(
mobileWarningSoft, mobileWarningSoft,
mobileWarning, mobileWarning,
mobileWarning, mobileWarning,
Color(0xFFEED8B8), LocalMobileColors.current.chipBorderWarning,
) )
StatusVisual.Error -> StatusVisual.Error ->
listOf( listOf(
mobileDangerSoft, mobileDangerSoft,
mobileDanger, mobileDanger,
mobileDanger, mobileDanger,
Color(0xFFF3C8C8), LocalMobileColors.current.chipBorderError,
) )
StatusVisual.Offline -> StatusVisual.Offline ->
listOf( listOf(
@ -249,7 +249,7 @@ private fun BottomTabBar(
) { ) {
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
color = Color.White.copy(alpha = 0.97f), color = mobileCardSurface.copy(alpha = 0.97f),
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
border = BorderStroke(1.dp, mobileBorder), border = BorderStroke(1.dp, mobileBorder),
shadowElevation = 6.dp, shadowElevation = 6.dp,
@ -270,7 +270,7 @@ private fun BottomTabBar(
modifier = Modifier.weight(1f).heightIn(min = 58.dp), modifier = Modifier.weight(1f).heightIn(min = 58.dp),
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
color = if (active) mobileAccentSoft else Color.Transparent, color = if (active) mobileAccentSoft else Color.Transparent,
border = if (active) BorderStroke(1.dp, Color(0xFFD5E2FA)) else null, border = if (active) BorderStroke(1.dp, LocalMobileColors.current.chipBorderConnecting) else null,
shadowElevation = 0.dp, shadowElevation = 0.dp,
) { ) {
Column( Column(

View File

@ -218,6 +218,18 @@ fun SettingsSheet(viewModel: MainViewModel) {
calendarPermissionGranted = readOk && writeOk calendarPermissionGranted = readOk && writeOk
} }
var callLogPermissionGranted by
remember {
mutableStateOf(
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALL_LOG) ==
PackageManager.PERMISSION_GRANTED,
)
}
val callLogPermissionLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
callLogPermissionGranted = granted
}
var motionPermissionGranted by var motionPermissionGranted by
remember { remember {
mutableStateOf( mutableStateOf(
@ -266,6 +278,9 @@ fun SettingsSheet(viewModel: MainViewModel) {
PackageManager.PERMISSION_GRANTED && PackageManager.PERMISSION_GRANTED &&
ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) == ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_CALENDAR) ==
PackageManager.PERMISSION_GRANTED PackageManager.PERMISSION_GRANTED
callLogPermissionGranted =
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CALL_LOG) ==
PackageManager.PERMISSION_GRANTED
motionPermissionGranted = motionPermissionGranted =
!motionPermissionRequired || !motionPermissionRequired ||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) == ContextCompat.checkSelfPermission(context, Manifest.permission.ACTIVITY_RECOGNITION) ==
@ -601,6 +616,31 @@ fun SettingsSheet(viewModel: MainViewModel) {
} }
}, },
) )
HorizontalDivider(color = mobileBorder)
ListItem(
modifier = Modifier.fillMaxWidth(),
colors = listItemColors,
headlineContent = { Text("Call Log", style = mobileHeadline) },
supportingContent = { Text("Search recent call history.", style = mobileCallout) },
trailingContent = {
Button(
onClick = {
if (callLogPermissionGranted) {
openAppSettings(context)
} else {
callLogPermissionLauncher.launch(Manifest.permission.READ_CALL_LOG)
}
},
colors = settingsPrimaryButtonColors(),
shape = RoundedCornerShape(14.dp),
) {
Text(
if (callLogPermissionGranted) "Manage" else "Grant",
style = mobileCallout.copy(fontWeight = FontWeight.Bold),
)
}
},
)
if (motionAvailable) { if (motionAvailable) {
HorizontalDivider(color = mobileBorder) HorizontalDivider(color = mobileBorder)
ListItem( ListItem(
@ -736,11 +776,12 @@ private fun settingsTextFieldColors() =
cursorColor = mobileAccent, cursorColor = mobileAccent,
) )
@Composable
private fun Modifier.settingsRowModifier() = private fun Modifier.settingsRowModifier() =
this this
.fillMaxWidth() .fillMaxWidth()
.border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp)) .border(width = 1.dp, color = mobileBorder, shape = RoundedCornerShape(14.dp))
.background(Color.White, RoundedCornerShape(14.dp)) .background(mobileCardSurface, RoundedCornerShape(14.dp))
@Composable @Composable
private fun settingsPrimaryButtonColors() = private fun settingsPrimaryButtonColors() =

View File

@ -363,7 +363,7 @@ private fun VoiceTurnBubble(entry: VoiceConversationEntry) {
Surface( Surface(
modifier = Modifier.fillMaxWidth(0.90f), modifier = Modifier.fillMaxWidth(0.90f),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = if (isUser) mobileAccentSoft else Color.White, color = if (isUser) mobileAccentSoft else mobileCardSurface,
border = BorderStroke(1.dp, if (isUser) mobileAccent else mobileBorderStrong), border = BorderStroke(1.dp, if (isUser) mobileAccent else mobileBorderStrong),
) { ) {
Column( Column(
@ -391,7 +391,7 @@ private fun VoiceThinkingBubble() {
Surface( Surface(
modifier = Modifier.fillMaxWidth(0.68f), modifier = Modifier.fillMaxWidth(0.68f),
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = Color.White, color = mobileCardSurface,
border = BorderStroke(1.dp, mobileBorderStrong), border = BorderStroke(1.dp, mobileBorderStrong),
) { ) {
Row( Row(

View File

@ -46,11 +46,13 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import ai.openclaw.app.ui.mobileAccent import ai.openclaw.app.ui.mobileAccent
import ai.openclaw.app.ui.mobileAccentBorderStrong
import ai.openclaw.app.ui.mobileAccentSoft import ai.openclaw.app.ui.mobileAccentSoft
import ai.openclaw.app.ui.mobileBorder import ai.openclaw.app.ui.mobileBorder
import ai.openclaw.app.ui.mobileBorderStrong import ai.openclaw.app.ui.mobileBorderStrong
import ai.openclaw.app.ui.mobileCallout import ai.openclaw.app.ui.mobileCallout
import ai.openclaw.app.ui.mobileCaption1 import ai.openclaw.app.ui.mobileCaption1
import ai.openclaw.app.ui.mobileCardSurface
import ai.openclaw.app.ui.mobileHeadline import ai.openclaw.app.ui.mobileHeadline
import ai.openclaw.app.ui.mobileSurface import ai.openclaw.app.ui.mobileSurface
import ai.openclaw.app.ui.mobileText import ai.openclaw.app.ui.mobileText
@ -110,7 +112,7 @@ fun ChatComposer(
Surface( Surface(
onClick = { showThinkingMenu = true }, onClick = { showThinkingMenu = true },
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
color = Color.White, color = mobileCardSurface,
border = BorderStroke(1.dp, mobileBorderStrong), border = BorderStroke(1.dp, mobileBorderStrong),
) { ) {
Row( Row(
@ -126,7 +128,15 @@ fun ChatComposer(
} }
} }
DropdownMenu(expanded = showThinkingMenu, onDismissRequest = { showThinkingMenu = false }) { DropdownMenu(
expanded = showThinkingMenu,
onDismissRequest = { showThinkingMenu = false },
shape = RoundedCornerShape(16.dp),
containerColor = mobileCardSurface,
tonalElevation = 0.dp,
shadowElevation = 8.dp,
border = BorderStroke(1.dp, mobileBorder),
) {
ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } ThinkingMenuItem("off", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } ThinkingMenuItem("low", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false } ThinkingMenuItem("medium", thinkingLevel, onSetThinkingLevel) { showThinkingMenu = false }
@ -177,7 +187,7 @@ fun ChatComposer(
disabledContainerColor = mobileBorderStrong, disabledContainerColor = mobileBorderStrong,
disabledContentColor = mobileTextTertiary, disabledContentColor = mobileTextTertiary,
), ),
border = BorderStroke(1.dp, if (canSend) Color(0xFF154CAD) else mobileBorderStrong), border = BorderStroke(1.dp, if (canSend) mobileAccentBorderStrong else mobileBorderStrong),
) { ) {
if (sendBusy) { if (sendBusy) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = Color.White) CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = Color.White)
@ -211,9 +221,9 @@ private fun SecondaryActionButton(
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
colors = colors =
ButtonDefaults.buttonColors( ButtonDefaults.buttonColors(
containerColor = Color.White, containerColor = mobileCardSurface,
contentColor = mobileTextSecondary, contentColor = mobileTextSecondary,
disabledContainerColor = Color.White, disabledContainerColor = mobileCardSurface,
disabledContentColor = mobileTextTertiary, disabledContentColor = mobileTextTertiary,
), ),
border = BorderStroke(1.dp, mobileBorderStrong), border = BorderStroke(1.dp, mobileBorderStrong),
@ -303,7 +313,7 @@ private fun AttachmentChip(fileName: String, onRemove: () -> Unit) {
Surface( Surface(
onClick = onRemove, onClick = onRemove,
shape = RoundedCornerShape(999.dp), shape = RoundedCornerShape(999.dp),
color = Color.White, color = mobileCardSurface,
border = BorderStroke(1.dp, mobileBorderStrong), border = BorderStroke(1.dp, mobileBorderStrong),
) { ) {
Text( Text(

View File

@ -94,7 +94,7 @@ private val markdownParser: Parser by lazy {
@Composable @Composable
fun ChatMarkdown(text: String, textColor: Color) { fun ChatMarkdown(text: String, textColor: Color) {
val document = remember(text) { markdownParser.parse(text) as Document } val document = remember(text) { markdownParser.parse(text) as Document }
val inlineStyles = InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText) val inlineStyles = InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText, linkColor = mobileAccent, baseCallout = mobileCallout)
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
RenderMarkdownBlocks( RenderMarkdownBlocks(
@ -124,7 +124,7 @@ private fun RenderMarkdownBlocks(
val headingText = remember(current) { buildInlineMarkdown(current.firstChild, inlineStyles) } val headingText = remember(current) { buildInlineMarkdown(current.firstChild, inlineStyles) }
Text( Text(
text = headingText, text = headingText,
style = headingStyle(current.level), style = headingStyle(current.level, inlineStyles.baseCallout),
color = textColor, color = textColor,
) )
} }
@ -231,7 +231,7 @@ private fun RenderParagraph(
Text( Text(
text = annotated, text = annotated,
style = mobileCallout, style = inlineStyles.baseCallout,
color = textColor, color = textColor,
) )
} }
@ -315,7 +315,7 @@ private fun RenderListItem(
) { ) {
Text( Text(
text = marker, text = marker,
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold), style = inlineStyles.baseCallout.copy(fontWeight = FontWeight.SemiBold),
color = textColor, color = textColor,
modifier = Modifier.width(24.dp), modifier = Modifier.width(24.dp),
) )
@ -360,7 +360,7 @@ private fun RenderTableBlock(
val cell = row.cells.getOrNull(index) ?: AnnotatedString("") val cell = row.cells.getOrNull(index) ?: AnnotatedString("")
Text( Text(
text = cell, text = cell,
style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else mobileCallout, style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else inlineStyles.baseCallout,
color = textColor, color = textColor,
modifier = Modifier modifier = Modifier
.border(1.dp, mobileTextSecondary.copy(alpha = 0.22f)) .border(1.dp, mobileTextSecondary.copy(alpha = 0.22f))
@ -417,6 +417,7 @@ private fun buildInlineMarkdown(start: Node?, inlineStyles: InlineStyles): Annot
node = start, node = start,
inlineCodeBg = inlineStyles.inlineCodeBg, inlineCodeBg = inlineStyles.inlineCodeBg,
inlineCodeColor = inlineStyles.inlineCodeColor, inlineCodeColor = inlineStyles.inlineCodeColor,
linkColor = inlineStyles.linkColor,
) )
} }
} }
@ -425,6 +426,7 @@ private fun AnnotatedString.Builder.appendInlineNode(
node: Node?, node: Node?,
inlineCodeBg: Color, inlineCodeBg: Color,
inlineCodeColor: Color, inlineCodeColor: Color,
linkColor: Color,
) { ) {
var current = node var current = node
while (current != null) { while (current != null) {
@ -445,27 +447,27 @@ private fun AnnotatedString.Builder.appendInlineNode(
} }
is Emphasis -> { is Emphasis -> {
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
} }
} }
is StrongEmphasis -> { is StrongEmphasis -> {
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) { withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
} }
} }
is Strikethrough -> { is Strikethrough -> {
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) { withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
} }
} }
is Link -> { is Link -> {
withStyle( withStyle(
SpanStyle( SpanStyle(
color = mobileAccent, color = linkColor,
textDecoration = TextDecoration.Underline, textDecoration = TextDecoration.Underline,
), ),
) { ) {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
} }
} }
is MarkdownImage -> { is MarkdownImage -> {
@ -482,7 +484,7 @@ private fun AnnotatedString.Builder.appendInlineNode(
} }
} }
else -> { else -> {
appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor) appendInlineNode(current.firstChild, inlineCodeBg = inlineCodeBg, inlineCodeColor = inlineCodeColor, linkColor = linkColor)
} }
} }
current = current.next current = current.next
@ -519,19 +521,21 @@ private fun parseDataImageDestination(destination: String?): ParsedDataImage? {
return ParsedDataImage(mimeType = "image/$subtype", base64 = base64) return ParsedDataImage(mimeType = "image/$subtype", base64 = base64)
} }
private fun headingStyle(level: Int): TextStyle { private fun headingStyle(level: Int, baseCallout: TextStyle): TextStyle {
return when (level.coerceIn(1, 6)) { return when (level.coerceIn(1, 6)) {
1 -> mobileCallout.copy(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold) 1 -> baseCallout.copy(fontSize = 22.sp, lineHeight = 28.sp, fontWeight = FontWeight.Bold)
2 -> mobileCallout.copy(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold) 2 -> baseCallout.copy(fontSize = 20.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold)
3 -> mobileCallout.copy(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold) 3 -> baseCallout.copy(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold)
4 -> mobileCallout.copy(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold) 4 -> baseCallout.copy(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold)
else -> mobileCallout.copy(fontWeight = FontWeight.SemiBold) else -> baseCallout.copy(fontWeight = FontWeight.SemiBold)
} }
} }
private data class InlineStyles( private data class InlineStyles(
val inlineCodeBg: Color, val inlineCodeBg: Color,
val inlineCodeColor: Color, val inlineCodeColor: Color,
val linkColor: Color,
val baseCallout: TextStyle,
) )
private data class TableRenderRow( private data class TableRenderRow(

View File

@ -19,6 +19,7 @@ import ai.openclaw.app.chat.ChatMessage
import ai.openclaw.app.chat.ChatPendingToolCall import ai.openclaw.app.chat.ChatPendingToolCall
import ai.openclaw.app.ui.mobileBorder import ai.openclaw.app.ui.mobileBorder
import ai.openclaw.app.ui.mobileCallout import ai.openclaw.app.ui.mobileCallout
import ai.openclaw.app.ui.mobileCardSurface
import ai.openclaw.app.ui.mobileHeadline import ai.openclaw.app.ui.mobileHeadline
import ai.openclaw.app.ui.mobileText import ai.openclaw.app.ui.mobileText
import ai.openclaw.app.ui.mobileTextSecondary import ai.openclaw.app.ui.mobileTextSecondary
@ -85,7 +86,7 @@ private fun EmptyChatHint(modifier: Modifier = Modifier, healthOk: Boolean) {
Surface( Surface(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
color = androidx.compose.ui.graphics.Color.White.copy(alpha = 0.9f), color = mobileCardSurface.copy(alpha = 0.9f),
border = androidx.compose.foundation.BorderStroke(1.dp, mobileBorder), border = androidx.compose.foundation.BorderStroke(1.dp, mobileBorder),
) { ) {
androidx.compose.foundation.layout.Column( androidx.compose.foundation.layout.Column(

View File

@ -36,7 +36,9 @@ import ai.openclaw.app.ui.mobileBorderStrong
import ai.openclaw.app.ui.mobileCallout import ai.openclaw.app.ui.mobileCallout
import ai.openclaw.app.ui.mobileCaption1 import ai.openclaw.app.ui.mobileCaption1
import ai.openclaw.app.ui.mobileCaption2 import ai.openclaw.app.ui.mobileCaption2
import ai.openclaw.app.ui.mobileCardSurface
import ai.openclaw.app.ui.mobileCodeBg import ai.openclaw.app.ui.mobileCodeBg
import ai.openclaw.app.ui.mobileCodeBorder
import ai.openclaw.app.ui.mobileCodeText import ai.openclaw.app.ui.mobileCodeText
import ai.openclaw.app.ui.mobileHeadline import ai.openclaw.app.ui.mobileHeadline
import ai.openclaw.app.ui.mobileText import ai.openclaw.app.ui.mobileText
@ -194,6 +196,7 @@ fun ChatStreamingAssistantBubble(text: String) {
} }
} }
@Composable
private fun bubbleStyle(role: String): ChatBubbleStyle { private fun bubbleStyle(role: String): ChatBubbleStyle {
return when (role) { return when (role) {
"user" -> "user" ->
@ -215,7 +218,7 @@ private fun bubbleStyle(role: String): ChatBubbleStyle {
else -> else ->
ChatBubbleStyle( ChatBubbleStyle(
alignEnd = false, alignEnd = false,
containerColor = Color.White, containerColor = mobileCardSurface,
borderColor = mobileBorderStrong, borderColor = mobileBorderStrong,
roleColor = mobileTextSecondary, roleColor = mobileTextSecondary,
) )
@ -239,7 +242,7 @@ private fun ChatBase64Image(base64: String, mimeType: String?) {
Surface( Surface(
shape = RoundedCornerShape(10.dp), shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, mobileBorder), border = BorderStroke(1.dp, mobileBorder),
color = Color.White, color = mobileCardSurface,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Image( Image(
@ -277,7 +280,7 @@ fun ChatCodeBlock(code: String, language: String?) {
Surface( Surface(
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
color = mobileCodeBg, color = mobileCodeBg,
border = BorderStroke(1.dp, Color(0xFF2B2E35)), border = BorderStroke(1.dp, mobileCodeBorder),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {

View File

@ -36,12 +36,15 @@ import ai.openclaw.app.MainViewModel
import ai.openclaw.app.chat.ChatSessionEntry import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.chat.OutgoingAttachment import ai.openclaw.app.chat.OutgoingAttachment
import ai.openclaw.app.ui.mobileAccent import ai.openclaw.app.ui.mobileAccent
import ai.openclaw.app.ui.mobileAccentBorderStrong
import ai.openclaw.app.ui.mobileBorder import ai.openclaw.app.ui.mobileBorder
import ai.openclaw.app.ui.mobileBorderStrong import ai.openclaw.app.ui.mobileBorderStrong
import ai.openclaw.app.ui.mobileCallout import ai.openclaw.app.ui.mobileCallout
import ai.openclaw.app.ui.mobileCardSurface
import ai.openclaw.app.ui.mobileCaption1 import ai.openclaw.app.ui.mobileCaption1
import ai.openclaw.app.ui.mobileCaption2 import ai.openclaw.app.ui.mobileCaption2
import ai.openclaw.app.ui.mobileDanger import ai.openclaw.app.ui.mobileDanger
import ai.openclaw.app.ui.mobileDangerSoft
import ai.openclaw.app.ui.mobileText import ai.openclaw.app.ui.mobileText
import ai.openclaw.app.ui.mobileTextSecondary import ai.openclaw.app.ui.mobileTextSecondary
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
@ -168,8 +171,8 @@ private fun ChatThreadSelector(
Surface( Surface(
onClick = { onSelectSession(entry.key) }, onClick = { onSelectSession(entry.key) },
shape = RoundedCornerShape(14.dp), shape = RoundedCornerShape(14.dp),
color = if (active) mobileAccent else Color.White, color = if (active) mobileAccent else mobileCardSurface,
border = BorderStroke(1.dp, if (active) Color(0xFF154CAD) else mobileBorderStrong), border = BorderStroke(1.dp, if (active) mobileAccentBorderStrong else mobileBorderStrong),
tonalElevation = 0.dp, tonalElevation = 0.dp,
shadowElevation = 0.dp, shadowElevation = 0.dp,
) { ) {
@ -190,7 +193,7 @@ private fun ChatThreadSelector(
private fun ChatErrorRail(errorText: String) { private fun ChatErrorRail(errorText: String) {
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
color = androidx.compose.ui.graphics.Color.White, color = mobileDangerSoft,
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, mobileDanger), border = androidx.compose.foundation.BorderStroke(1.dp, mobileDanger),
) { ) {

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.OpenClawNode" parent="Theme.Material3.DayNight.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowLightStatusBar">false</item>
</style>
</resources>

View File

@ -0,0 +1,193 @@
package ai.openclaw.app.node
import android.content.Context
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CallLogHandlerTest : NodeHandlerRobolectricTest() {
@Test
fun handleCallLogSearch_requiresPermission() {
val handler = CallLogHandler.forTesting(appContext(), FakeCallLogDataSource(canRead = false))
val result = handler.handleCallLogSearch(null)
assertFalse(result.ok)
assertEquals("CALL_LOG_PERMISSION_REQUIRED", result.error?.code)
}
@Test
fun handleCallLogSearch_rejectsInvalidJson() {
val handler = CallLogHandler.forTesting(appContext(), FakeCallLogDataSource(canRead = true))
val result = handler.handleCallLogSearch("invalid json")
assertFalse(result.ok)
assertEquals("INVALID_REQUEST", result.error?.code)
}
@Test
fun handleCallLogSearch_returnsCallLogs() {
val callLog =
CallLogRecord(
number = "+123456",
cachedName = "lixuankai",
date = 1709280000000L,
duration = 60L,
type = 1,
)
val handler =
CallLogHandler.forTesting(
appContext(),
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
)
val result = handler.handleCallLogSearch("""{"limit":1}""")
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val callLogs = payload.getValue("callLogs").jsonArray
assertEquals(1, callLogs.size)
assertEquals("+123456", callLogs.first().jsonObject.getValue("number").jsonPrimitive.content)
assertEquals("lixuankai", callLogs.first().jsonObject.getValue("cachedName").jsonPrimitive.content)
assertEquals(1709280000000L, callLogs.first().jsonObject.getValue("date").jsonPrimitive.content.toLong())
assertEquals(60L, callLogs.first().jsonObject.getValue("duration").jsonPrimitive.content.toLong())
assertEquals(1, callLogs.first().jsonObject.getValue("type").jsonPrimitive.content.toInt())
}
@Test
fun handleCallLogSearch_withFilters() {
val callLog =
CallLogRecord(
number = "+123456",
cachedName = "lixuankai",
date = 1709280000000L,
duration = 120L,
type = 2,
)
val handler =
CallLogHandler.forTesting(
appContext(),
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
)
val result = handler.handleCallLogSearch(
"""{"number":"123456","cachedName":"lixuankai","dateStart":1709270000000,"dateEnd":1709290000000,"duration":120,"type":2}"""
)
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val callLogs = payload.getValue("callLogs").jsonArray
assertEquals(1, callLogs.size)
assertEquals("lixuankai", callLogs.first().jsonObject.getValue("cachedName").jsonPrimitive.content)
}
@Test
fun handleCallLogSearch_withPagination() {
val callLogs =
listOf(
CallLogRecord(
number = "+123456",
cachedName = "lixuankai",
date = 1709280000000L,
duration = 60L,
type = 1,
),
CallLogRecord(
number = "+654321",
cachedName = "lixuankai2",
date = 1709280001000L,
duration = 120L,
type = 2,
),
)
val handler =
CallLogHandler.forTesting(
appContext(),
FakeCallLogDataSource(canRead = true, searchResults = callLogs),
)
val result = handler.handleCallLogSearch("""{"limit":1,"offset":1}""")
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val callLogsResult = payload.getValue("callLogs").jsonArray
assertEquals(1, callLogsResult.size)
assertEquals("lixuankai2", callLogsResult.first().jsonObject.getValue("cachedName").jsonPrimitive.content)
}
@Test
fun handleCallLogSearch_withDefaultParams() {
val callLog =
CallLogRecord(
number = "+123456",
cachedName = "lixuankai",
date = 1709280000000L,
duration = 60L,
type = 1,
)
val handler =
CallLogHandler.forTesting(
appContext(),
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
)
val result = handler.handleCallLogSearch(null)
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val callLogs = payload.getValue("callLogs").jsonArray
assertEquals(1, callLogs.size)
assertEquals("+123456", callLogs.first().jsonObject.getValue("number").jsonPrimitive.content)
}
@Test
fun handleCallLogSearch_withNullFields() {
val callLog =
CallLogRecord(
number = null,
cachedName = null,
date = 1709280000000L,
duration = 60L,
type = 1,
)
val handler =
CallLogHandler.forTesting(
appContext(),
FakeCallLogDataSource(canRead = true, searchResults = listOf(callLog)),
)
val result = handler.handleCallLogSearch("""{"limit":1}""")
assertTrue(result.ok)
val payload = Json.parseToJsonElement(result.payloadJson ?: error("missing payload")).jsonObject
val callLogs = payload.getValue("callLogs").jsonArray
assertEquals(1, callLogs.size)
// Verify null values are properly serialized
val callLogObj = callLogs.first().jsonObject
assertTrue(callLogObj.containsKey("number"))
assertTrue(callLogObj.containsKey("cachedName"))
}
}
private class FakeCallLogDataSource(
private val canRead: Boolean,
private val searchResults: List<CallLogRecord> = emptyList(),
) : CallLogDataSource {
override fun hasReadPermission(context: Context): Boolean = canRead
override fun search(context: Context, request: CallLogSearchRequest): List<CallLogRecord> {
val startIndex = request.offset.coerceAtLeast(0)
val endIndex = (startIndex + request.limit).coerceAtMost(searchResults.size)
return if (startIndex < searchResults.size) {
searchResults.subList(startIndex, endIndex)
} else {
emptyList()
}
}
}

View File

@ -93,6 +93,7 @@ class DeviceHandlerTest {
"photos", "photos",
"contacts", "contacts",
"calendar", "calendar",
"callLog",
"motion", "motion",
) )
for (key in expected) { for (key in expected) {

View File

@ -2,6 +2,7 @@ package ai.openclaw.app.node
import ai.openclaw.app.protocol.OpenClawCalendarCommand import ai.openclaw.app.protocol.OpenClawCalendarCommand
import ai.openclaw.app.protocol.OpenClawCameraCommand import ai.openclaw.app.protocol.OpenClawCameraCommand
import ai.openclaw.app.protocol.OpenClawCallLogCommand
import ai.openclaw.app.protocol.OpenClawCapability import ai.openclaw.app.protocol.OpenClawCapability
import ai.openclaw.app.protocol.OpenClawContactsCommand import ai.openclaw.app.protocol.OpenClawContactsCommand
import ai.openclaw.app.protocol.OpenClawDeviceCommand import ai.openclaw.app.protocol.OpenClawDeviceCommand
@ -25,6 +26,7 @@ class InvokeCommandRegistryTest {
OpenClawCapability.Photos.rawValue, OpenClawCapability.Photos.rawValue,
OpenClawCapability.Contacts.rawValue, OpenClawCapability.Contacts.rawValue,
OpenClawCapability.Calendar.rawValue, OpenClawCapability.Calendar.rawValue,
OpenClawCapability.CallLog.rawValue,
) )
private val optionalCapabilities = private val optionalCapabilities =
@ -50,6 +52,7 @@ class InvokeCommandRegistryTest {
OpenClawContactsCommand.Add.rawValue, OpenClawContactsCommand.Add.rawValue,
OpenClawCalendarCommand.Events.rawValue, OpenClawCalendarCommand.Events.rawValue,
OpenClawCalendarCommand.Add.rawValue, OpenClawCalendarCommand.Add.rawValue,
OpenClawCallLogCommand.Search.rawValue,
) )
private val optionalCommands = private val optionalCommands =

View File

@ -34,6 +34,7 @@ class OpenClawProtocolConstantsTest {
assertEquals("contacts", OpenClawCapability.Contacts.rawValue) assertEquals("contacts", OpenClawCapability.Contacts.rawValue)
assertEquals("calendar", OpenClawCapability.Calendar.rawValue) assertEquals("calendar", OpenClawCapability.Calendar.rawValue)
assertEquals("motion", OpenClawCapability.Motion.rawValue) assertEquals("motion", OpenClawCapability.Motion.rawValue)
assertEquals("callLog", OpenClawCapability.CallLog.rawValue)
} }
@Test @Test
@ -84,4 +85,9 @@ class OpenClawProtocolConstantsTest {
assertEquals("motion.activity", OpenClawMotionCommand.Activity.rawValue) assertEquals("motion.activity", OpenClawMotionCommand.Activity.rawValue)
assertEquals("motion.pedometer", OpenClawMotionCommand.Pedometer.rawValue) assertEquals("motion.pedometer", OpenClawMotionCommand.Pedometer.rawValue)
} }
@Test
fun callLogCommandsUseStableStrings() {
assertEquals("callLog.search", OpenClawCallLogCommand.Search.rawValue)
}
} }

View File

@ -18,13 +18,10 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) { func userContentController(_: WKUserContentController, didReceive message: WKScriptMessage) {
guard Self.allMessageNames.contains(message.name) else { return } guard Self.allMessageNames.contains(message.name) else { return }
// Only accept actions from local Canvas content (not arbitrary web pages). // Only accept actions from the in-app canvas scheme. Local-network HTTP
// pages are regular web content and must not get direct agent dispatch.
guard let webView = message.webView, let url = webView.url else { return } guard let webView = message.webView, let url = webView.url else { return }
if let scheme = url.scheme, CanvasScheme.allSchemes.contains(scheme) { guard let scheme = url.scheme, CanvasScheme.allSchemes.contains(scheme) else {
// ok
} else if Self.isLocalNetworkCanvasURL(url) {
// ok
} else {
return return
} }
@ -107,10 +104,5 @@ final class CanvasA2UIActionMessageHandler: NSObject, WKScriptMessageHandler {
} }
} }
} }
static func isLocalNetworkCanvasURL(_ url: URL) -> Bool {
LocalNetworkURLSupport.isLocalNetworkHTTPURL(url)
}
// Formatting helpers live in OpenClawKit (`OpenClawCanvasA2UIAction`). // Formatting helpers live in OpenClawKit (`OpenClawCanvasA2UIAction`).
} }

View File

@ -50,21 +50,24 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
// Bridge A2UI "a2uiaction" DOM events back into the native agent loop. // Bridge A2UI "a2uiaction" DOM events back into the native agent loop.
// //
// Prefer WKScriptMessageHandler when WebKit exposes it, otherwise fall back to an unattended deep link // Keep the bridge on the trusted in-app canvas scheme only, and do not
// (includes the app-generated key so it won't prompt). // expose unattended deep-link credentials to page JavaScript.
canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script") canvasWindowLogger.debug("CanvasWindowController init building A2UI bridge script")
let deepLinkKey = DeepLinkHandler.currentCanvasKey()
let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main" let injectedSessionKey = sessionKey.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "main"
let allowedSchemesJSON = (
try? String(
data: JSONSerialization.data(withJSONObject: CanvasScheme.allSchemes),
encoding: .utf8)
) ?? "[]"
let bridgeScript = """ let bridgeScript = """
(() => { (() => {
try { try {
const allowedSchemes = \(String(describing: CanvasScheme.allSchemes)); const allowedSchemes = \(allowedSchemesJSON);
const protocol = location.protocol.replace(':', ''); const protocol = location.protocol.replace(':', '');
if (!allowedSchemes.includes(protocol)) return; if (!allowedSchemes.includes(protocol)) return;
if (globalThis.__openclawA2UIBridgeInstalled) return; if (globalThis.__openclawA2UIBridgeInstalled) return;
globalThis.__openclawA2UIBridgeInstalled = true; globalThis.__openclawA2UIBridgeInstalled = true;
const deepLinkKey = \(Self.jsStringLiteral(deepLinkKey));
const sessionKey = \(Self.jsStringLiteral(injectedSessionKey)); const sessionKey = \(Self.jsStringLiteral(injectedSessionKey));
const machineName = \(Self.jsStringLiteral(InstanceIdentity.displayName)); const machineName = \(Self.jsStringLiteral(InstanceIdentity.displayName));
const instanceId = \(Self.jsStringLiteral(InstanceIdentity.instanceId)); const instanceId = \(Self.jsStringLiteral(InstanceIdentity.instanceId));
@ -104,24 +107,8 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
return; return;
} }
const ctx = userAction.context ? (' ctx=' + JSON.stringify(userAction.context)) : ''; // Without the native handler, fail closed instead of exposing an
const message = // unattended deep-link credential to page JavaScript.
'CANVAS_A2UI action=' + userAction.name +
' session=' + sessionKey +
' surface=' + userAction.surfaceId +
' component=' + (userAction.sourceComponentId || '-') +
' host=' + machineName.replace(/\\s+/g, '_') +
' instance=' + instanceId +
ctx +
' default=update_canvas';
const params = new URLSearchParams();
params.set('message', message);
params.set('sessionKey', sessionKey);
params.set('thinking', 'low');
params.set('deliver', 'false');
params.set('channel', 'last');
params.set('key', deepLinkKey);
location.href = 'openclaw://agent?' + params.toString();
} catch {} } catch {}
}, true); }, true);
} catch {} } catch {}

View File

@ -1484,6 +1484,16 @@
"tags": [], "tags": [],
"hasChildren": false "hasChildren": false
}, },
{
"path": "agents.defaults.heartbeat.isolatedSession",
"kind": "core",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{ {
"path": "agents.defaults.heartbeat.lightContext", "path": "agents.defaults.heartbeat.lightContext",
"kind": "core", "kind": "core",
@ -1544,7 +1554,7 @@
"deprecated": false, "deprecated": false,
"sensitive": false, "sensitive": false,
"tags": ["automation"], "tags": ["automation"],
"help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.", "help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.",
"hasChildren": false "hasChildren": false
}, },
{ {
@ -3647,6 +3657,16 @@
"tags": [], "tags": [],
"hasChildren": false "hasChildren": false
}, },
{
"path": "agents.list.*.heartbeat.isolatedSession",
"kind": "core",
"type": "boolean",
"required": false,
"deprecated": false,
"sensitive": false,
"tags": [],
"hasChildren": false
},
{ {
"path": "agents.list.*.heartbeat.lightContext", "path": "agents.list.*.heartbeat.lightContext",
"kind": "core", "kind": "core",
@ -3707,7 +3727,7 @@
"deprecated": false, "deprecated": false,
"sensitive": false, "sensitive": false,
"tags": ["automation"], "tags": ["automation"],
"help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.", "help": "Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.",
"hasChildren": false "hasChildren": false
}, },
{ {

View File

@ -1,4 +1,4 @@
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":4731} {"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":4733}
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true} {"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true} {"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
@ -137,12 +137,13 @@
{"recordType":"path","path":"agents.defaults.heartbeat.directPolicy","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["access","automation","storage"],"label":"Heartbeat Direct Policy","help":"Controls whether heartbeat delivery may target direct/DM chats: \"allow\" (default) permits DM delivery and \"block\" suppresses direct-target sends.","hasChildren":false} {"recordType":"path","path":"agents.defaults.heartbeat.directPolicy","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["access","automation","storage"],"label":"Heartbeat Direct Policy","help":"Controls whether heartbeat delivery may target direct/DM chats: \"allow\" (default) permits DM delivery and \"block\" suppresses direct-target sends.","hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.every","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.defaults.heartbeat.every","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.includeReasoning","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.defaults.heartbeat.includeReasoning","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.isolatedSession","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.lightContext","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.defaults.heartbeat.lightContext","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.defaults.heartbeat.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.defaults.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.session","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.defaults.heartbeat.session","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.suppressToolErrorWarnings","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"label":"Heartbeat Suppress Tool Error Warnings","help":"Suppress tool error warning payloads during heartbeat runs.","hasChildren":false} {"recordType":"path","path":"agents.defaults.heartbeat.suppressToolErrorWarnings","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"label":"Heartbeat Suppress Tool Error Warnings","help":"Suppress tool error warning payloads during heartbeat runs.","hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.","hasChildren":false} {"recordType":"path","path":"agents.defaults.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.","hasChildren":false}
{"recordType":"path","path":"agents.defaults.heartbeat.to","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.defaults.heartbeat.to","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.defaults.humanDelay","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.defaults.humanDelay","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"agents.defaults.humanDelay.maxMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Human Delay Max (ms)","help":"Maximum delay in ms for custom humanDelay (default: 2500).","hasChildren":false} {"recordType":"path","path":"agents.defaults.humanDelay.maxMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Human Delay Max (ms)","help":"Maximum delay in ms for custom humanDelay (default: 2500).","hasChildren":false}
@ -340,12 +341,13 @@
{"recordType":"path","path":"agents.list.*.heartbeat.directPolicy","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["access","automation","storage"],"label":"Heartbeat Direct Policy","help":"Per-agent override for heartbeat direct/DM delivery policy; use \"block\" for agents that should only send heartbeat alerts to non-DM destinations.","hasChildren":false} {"recordType":"path","path":"agents.list.*.heartbeat.directPolicy","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["access","automation","storage"],"label":"Heartbeat Direct Policy","help":"Per-agent override for heartbeat direct/DM delivery policy; use \"block\" for agents that should only send heartbeat alerts to non-DM destinations.","hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.every","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.heartbeat.every","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.includeReasoning","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.heartbeat.includeReasoning","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.isolatedSession","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.lightContext","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.heartbeat.lightContext","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.heartbeat.model","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.session","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.heartbeat.session","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.suppressToolErrorWarnings","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"label":"Agent Heartbeat Suppress Tool Error Warnings","help":"Suppress tool error warning payloads during heartbeat runs.","hasChildren":false} {"recordType":"path","path":"agents.list.*.heartbeat.suppressToolErrorWarnings","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"label":"Agent Heartbeat Suppress Tool Error Warnings","help":"Suppress tool error warning payloads during heartbeat runs.","hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.","hasChildren":false} {"recordType":"path","path":"agents.list.*.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.","hasChildren":false}
{"recordType":"path","path":"agents.list.*.heartbeat.to","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.heartbeat.to","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
{"recordType":"path","path":"agents.list.*.humanDelay","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true} {"recordType":"path","path":"agents.list.*.humanDelay","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
{"recordType":"path","path":"agents.list.*.humanDelay.maxMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false} {"recordType":"path","path":"agents.list.*.humanDelay.maxMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 64 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 64 KiB

View File

@ -186,20 +186,15 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
Kimi K2 model IDs: Kimi K2 model IDs:
<!-- markdownlint-disable MD037 --> [//]: # "moonshot-kimi-k2-model-refs:start"
{/_ moonshot-kimi-k2-model-refs:start _/ && null}
<!-- markdownlint-enable MD037 -->
- `moonshot/kimi-k2.5` - `moonshot/kimi-k2.5`
- `moonshot/kimi-k2-0905-preview` - `moonshot/kimi-k2-0905-preview`
- `moonshot/kimi-k2-turbo-preview` - `moonshot/kimi-k2-turbo-preview`
- `moonshot/kimi-k2-thinking` - `moonshot/kimi-k2-thinking`
- `moonshot/kimi-k2-thinking-turbo` - `moonshot/kimi-k2-thinking-turbo`
<!-- markdownlint-disable MD037 -->
{/_ moonshot-kimi-k2-model-refs:end _/ && null} [//]: # "moonshot-kimi-k2-model-refs:end"
<!-- markdownlint-enable MD037 -->
```json5 ```json5
{ {

View File

@ -1242,7 +1242,6 @@
"group": "Security", "group": "Security",
"pages": [ "pages": [
"security/formal-verification", "security/formal-verification",
"security/README",
"security/THREAT-MODEL-ATLAS", "security/THREAT-MODEL-ATLAS",
"security/CONTRIBUTING-THREAT-MODEL" "security/CONTRIBUTING-THREAT-MODEL"
] ]
@ -1598,7 +1597,6 @@
"zh-CN/tools/apply-patch", "zh-CN/tools/apply-patch",
"zh-CN/brave-search", "zh-CN/brave-search",
"zh-CN/perplexity", "zh-CN/perplexity",
"zh-CN/tools/diffs",
"zh-CN/tools/elevated", "zh-CN/tools/elevated",
"zh-CN/tools/exec", "zh-CN/tools/exec",
"zh-CN/tools/exec-approvals", "zh-CN/tools/exec-approvals",

View File

@ -975,6 +975,7 @@ Periodic heartbeat runs.
model: "openai/gpt-5.2-mini", model: "openai/gpt-5.2-mini",
includeReasoning: false, includeReasoning: false,
lightContext: false, // default: false; true keeps only HEARTBEAT.md from workspace bootstrap files lightContext: false, // default: false; true keeps only HEARTBEAT.md from workspace bootstrap files
isolatedSession: false, // default: false; true runs each heartbeat in a fresh session (no conversation history)
session: "main", session: "main",
to: "+15555550123", to: "+15555550123",
directPolicy: "allow", // allow (default) | block directPolicy: "allow", // allow (default) | block
@ -992,6 +993,7 @@ Periodic heartbeat runs.
- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs. - `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs.
- `directPolicy`: direct/DM delivery policy. `allow` (default) permits direct-target delivery. `block` suppresses direct-target delivery and emits `reason=dm-blocked`. - `directPolicy`: direct/DM delivery policy. `allow` (default) permits direct-target delivery. `block` suppresses direct-target delivery and emits `reason=dm-blocked`.
- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files. - `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
- `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Same isolation pattern as cron `sessionTarget: "isolated"`. Reduces per-heartbeat token cost from ~100K to ~2-5K tokens.
- Per-agent: set `agents.list[].heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats. - 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. - Heartbeats run full agent turns — shorter intervals burn more tokens.

View File

@ -22,7 +22,8 @@ Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
3. Decide where heartbeat messages should go (`target: "none"` is the default; set `target: "last"` to route to the last contact). 3. Decide where heartbeat messages should go (`target: "none"` is the default; set `target: "last"` to route to the last contact).
4. Optional: enable heartbeat reasoning delivery for transparency. 4. Optional: enable heartbeat reasoning delivery for transparency.
5. Optional: use lightweight bootstrap context if heartbeat runs only need `HEARTBEAT.md`. 5. Optional: use lightweight bootstrap context if heartbeat runs only need `HEARTBEAT.md`.
6. Optional: restrict heartbeats to active hours (local time). 6. Optional: enable isolated sessions to avoid sending full conversation history each heartbeat.
7. Optional: restrict heartbeats to active hours (local time).
Example config: Example config:
@ -35,6 +36,7 @@ Example config:
target: "last", // explicit delivery to last contact (default is "none") target: "last", // explicit delivery to last contact (default is "none")
directPolicy: "allow", // default: allow direct/DM targets; set "block" to suppress directPolicy: "allow", // default: allow direct/DM targets; set "block" to suppress
lightContext: true, // optional: only inject HEARTBEAT.md from bootstrap files lightContext: true, // optional: only inject HEARTBEAT.md from bootstrap files
isolatedSession: true, // optional: fresh session each run (no conversation history)
// activeHours: { start: "08:00", end: "24:00" }, // activeHours: { start: "08:00", end: "24:00" },
// includeReasoning: true, // optional: send separate `Reasoning:` message too // includeReasoning: true, // optional: send separate `Reasoning:` message too
}, },
@ -91,6 +93,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
model: "anthropic/claude-opus-4-6", model: "anthropic/claude-opus-4-6",
includeReasoning: false, // default: false (deliver separate Reasoning: message when available) includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
lightContext: false, // default: false; true keeps only HEARTBEAT.md from workspace bootstrap files lightContext: false, // default: false; true keeps only HEARTBEAT.md from workspace bootstrap files
isolatedSession: false, // default: false; true runs each heartbeat in a fresh session (no conversation history)
target: "last", // default: none | options: last | none | <channel id> (core or plugin, e.g. "bluebubbles") target: "last", // default: none | options: last | none | <channel id> (core or plugin, e.g. "bluebubbles")
to: "+15551234567", // optional channel-specific override to: "+15551234567", // optional channel-specific override
accountId: "ops-bot", // optional multi-account channel id accountId: "ops-bot", // optional multi-account channel id
@ -212,6 +215,7 @@ Use `accountId` to target a specific account on multi-account channels like Tele
- `model`: optional model override for heartbeat runs (`provider/model`). - `model`: optional model override for heartbeat runs (`provider/model`).
- `includeReasoning`: when enabled, also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`). - `includeReasoning`: when enabled, also deliver the separate `Reasoning:` message when available (same shape as `/reasoning on`).
- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files. - `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
- `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Uses the same isolation pattern as cron `sessionTarget: "isolated"`. Dramatically reduces per-heartbeat token cost. Combine with `lightContext: true` for maximum savings. Delivery routing still uses the main session context.
- `session`: optional session key for heartbeat runs. - `session`: optional session key for heartbeat runs.
- `main` (default): agent main session. - `main` (default): agent main session.
- Explicit session key (copy from `openclaw sessions --json` or the [sessions CLI](/cli/sessions)). - Explicit session key (copy from `openclaw sessions --json` or the [sessions CLI](/cli/sessions)).
@ -380,6 +384,10 @@ off in group chats.
## Cost awareness ## Cost awareness
Heartbeats run full agent turns. Shorter intervals burn more tokens. Keep Heartbeats run full agent turns. Shorter intervals burn more tokens. To reduce cost:
`HEARTBEAT.md` small and consider a cheaper `model` or `target: "none"` if you
only want internal state updates. - Use `isolatedSession: true` to avoid sending full conversation history (~100K tokens down to ~2-5K per run).
- Use `lightContext: true` to limit bootstrap files to just `HEARTBEAT.md`.
- Set a cheaper `model` (e.g. `ollama/llama3.2:1b`).
- Keep `HEARTBEAT.md` small.
- Use `target: "none"` if you only want internal state updates.

View File

@ -285,6 +285,7 @@ Available families:
- `photos.latest` - `photos.latest`
- `contacts.search`, `contacts.add` - `contacts.search`, `contacts.add`
- `calendar.events`, `calendar.add` - `calendar.events`, `calendar.add`
- `callLog.search`
- `motion.activity`, `motion.pedometer` - `motion.activity`, `motion.pedometer`
Example invokes: Example invokes:

View File

@ -16,7 +16,7 @@ If you use `OPENROUTER_API_KEY`, an `sk-or-...` key in `tools.web.search.perplex
## Getting a Perplexity API key ## Getting a Perplexity API key
1. Create a Perplexity account at <https://www.perplexity.ai/settings/api> 1. Create a Perplexity account at [perplexity.ai/settings/api](https://www.perplexity.ai/settings/api)
2. Generate an API key in the dashboard 2. Generate an API key in the dashboard
3. Store the key in config or set `PERPLEXITY_API_KEY` in the Gateway environment. 3. Store the key in config or set `PERPLEXITY_API_KEY` in the Gateway environment.

View File

@ -163,4 +163,5 @@ See [Camera node](/nodes/camera) for parameters and CLI helpers.
- `photos.latest` - `photos.latest`
- `contacts.search`, `contacts.add` - `contacts.search`, `contacts.add`
- `calendar.events`, `calendar.add` - `calendar.events`, `calendar.add`
- `callLog.search`
- `motion.activity`, `motion.pedometer` - `motion.activity`, `motion.pedometer`

View File

@ -15,20 +15,15 @@ Kimi Coding with `kimi-coding/k2p5`.
Current Kimi K2 model IDs: Current Kimi K2 model IDs:
<!-- markdownlint-disable MD037 --> [//]: # "moonshot-kimi-k2-ids:start"
{/_ moonshot-kimi-k2-ids:start _/ && null}
<!-- markdownlint-enable MD037 -->
- `kimi-k2.5` - `kimi-k2.5`
- `kimi-k2-0905-preview` - `kimi-k2-0905-preview`
- `kimi-k2-turbo-preview` - `kimi-k2-turbo-preview`
- `kimi-k2-thinking` - `kimi-k2-thinking`
- `kimi-k2-thinking-turbo` - `kimi-k2-thinking-turbo`
<!-- markdownlint-disable MD037 -->
{/_ moonshot-kimi-k2-ids:end _/ && null} [//]: # "moonshot-kimi-k2-ids:end"
<!-- markdownlint-enable MD037 -->
```bash ```bash
openclaw onboard --auth-choice moonshot-api-key openclaw onboard --auth-choice moonshot-api-key

View File

@ -1,17 +0,0 @@
# OpenClaw Security & Trust
**Live:** [trust.openclaw.ai](https://trust.openclaw.ai)
## Documents
- [Threat Model](/security/THREAT-MODEL-ATLAS) - MITRE ATLAS-based threat model for the OpenClaw ecosystem
- [Contributing to the Threat Model](/security/CONTRIBUTING-THREAT-MODEL) - How to add threats, mitigations, and attack chains
## Reporting Vulnerabilities
See the [Trust page](https://trust.openclaw.ai) for full reporting instructions covering all repos.
## Contact
- **Jamieson O'Reilly** ([@theonejvo](https://twitter.com/theonejvo)) - Security & Trust
- Discord: #security channel

View File

@ -110,48 +110,6 @@ curl -s -X POST http://127.0.0.1:18791/start
curl -s http://127.0.0.1:18791/tabs curl -s http://127.0.0.1:18791/tabs
``` ```
## Existing-session MCP on Linux / VPS
If you want Chrome DevTools MCP instead of the managed `openclaw` CDP profile,
you now have two Linux-safe options:
1. Let MCP launch headless Chrome for an `existing-session` profile:
```json
{
"browser": {
"headless": true,
"noSandbox": true,
"executablePath": "/usr/bin/google-chrome-stable",
"defaultProfile": "user"
}
}
```
2. Attach MCP to a running debuggable Chrome instance:
```json
{
"browser": {
"headless": true,
"defaultProfile": "user",
"profiles": {
"user": {
"driver": "existing-session",
"cdpUrl": "http://127.0.0.1:9222",
"color": "#00AA00"
}
}
}
}
```
Notes:
- `driver: "existing-session"` still uses Chrome MCP transport, not the extension relay.
- `cdpUrl` on an `existing-session` profile is interpreted as the MCP browser target (`browserUrl` or `wsEndpoint`), not the normal OpenClaw CDP driver.
- If you omit `cdpUrl`, headless MCP launches Chrome itself.
### Config Reference ### Config Reference
| Option | Description | Default | | Option | Description | Default |

View File

@ -359,13 +359,9 @@ Notes:
## Chrome existing-session via MCP ## Chrome existing-session via MCP
OpenClaw can also use the official Chrome DevTools MCP server for two different OpenClaw can also attach to a running Chrome profile through the official
flows: Chrome DevTools MCP server. This reuses the tabs and login state already open in
that Chrome profile.
- desktop attach via `--autoConnect`, which reuses a running Chrome profile and
its existing tabs/login state
- headless or remote attach, where MCP either launches headless Chrome itself
or connects to a running debuggable browser URL/WS endpoint
Official background and setup references: Official background and setup references:
@ -379,7 +375,7 @@ Built-in profile:
Optional: create your own custom existing-session profile if you want a Optional: create your own custom existing-session profile if you want a
different name or color. different name or color.
Desktop attach flow: Then in Chrome:
1. Open `chrome://inspect/#remote-debugging` 1. Open `chrome://inspect/#remote-debugging`
2. Enable remote debugging 2. Enable remote debugging
@ -402,66 +398,30 @@ What success looks like:
- `tabs` lists your already-open Chrome tabs - `tabs` lists your already-open Chrome tabs
- `snapshot` returns refs from the selected live tab - `snapshot` returns refs from the selected live tab
What to check if desktop attach does not work: What to check if attach does not work:
- Chrome is version `144+` - Chrome is version `144+`
- remote debugging is enabled at `chrome://inspect/#remote-debugging` - remote debugging is enabled at `chrome://inspect/#remote-debugging`
- Chrome showed and you accepted the attach consent prompt - Chrome showed and you accepted the attach consent prompt
Headless / Linux / VPS flow:
- Set `browser.headless: true`
- Set `browser.noSandbox: true` when running as root or in common container/VPS setups
- Optional: set `browser.executablePath` to a stable Chrome/Chromium binary path
- Optional: set `browser.profiles.<name>.cdpUrl` on an `existing-session` profile to an
MCP target like `http://127.0.0.1:9222` or
`ws://127.0.0.1:9222/devtools/browser/<id>`
Example:
```json5
{
browser: {
headless: true,
noSandbox: true,
executablePath: "/usr/bin/google-chrome-stable",
defaultProfile: "user",
profiles: {
user: {
driver: "existing-session",
cdpUrl: "http://127.0.0.1:9222",
color: "#00AA00",
},
},
},
}
```
Behavior:
- without `browser.profiles.<name>.cdpUrl`, headless `existing-session` launches Chrome through MCP
- with `browser.profiles.<name>.cdpUrl`, MCP connects to that running browser URL
- non-headless `existing-session` keeps using the interactive `--autoConnect` flow
Agent use: Agent use:
- Use `profile="user"` when you need the users logged-in browser state. - Use `profile="user"` when you need the users logged-in browser state.
- If you use a custom existing-session profile, pass that explicit profile name. - If you use a custom existing-session profile, pass that explicit profile name.
- Prefer `profile="user"` over `profile="chrome-relay"` unless the user - Prefer `profile="user"` over `profile="chrome-relay"` unless the user
explicitly wants the extension / attach-tab flow. explicitly wants the extension / attach-tab flow.
- On desktop `--autoConnect`, only choose this mode when the user is at the - Only choose this mode when the user is at the computer to approve the attach
computer to approve the attach prompt. prompt.
- The Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect` - the Gateway or node host can spawn `npx chrome-devtools-mcp@latest --autoConnect`
for desktop attach, or use MCP headless/browserUrl/wsEndpoint modes for Linux/VPS paths.
Notes: Notes:
- This path is higher-risk than the isolated `openclaw` profile because it can - This path is higher-risk than the isolated `openclaw` profile because it can
act inside your signed-in browser session. act inside your signed-in browser session.
- OpenClaw uses the official Chrome DevTools MCP server for this driver. - OpenClaw does not launch Chrome for this driver; it attaches to an existing
- On desktop, OpenClaw uses MCP `--autoConnect`. session only.
- In headless mode, OpenClaw can launch Chrome through MCP or connect MCP to a - OpenClaw uses the official Chrome DevTools MCP `--autoConnect` flow here, not
configured browser URL/WS endpoint. the legacy default-profile remote debugging port workflow.
- Existing-session screenshots support page captures and `--ref` element - Existing-session screenshots support page captures and `--ref` element
captures from snapshots, but not CSS `--element` selectors. captures from snapshots, but not CSS `--element` selectors.
- Existing-session `wait --url` supports exact, substring, and glob patterns - Existing-session `wait --url` supports exact, substring, and glob patterns

View File

@ -160,13 +160,14 @@ Long options are validated fail-closed in safe-bin mode: unknown flags and ambig
abbreviations are rejected. abbreviations are rejected.
Denied flags by safe-bin profile: Denied flags by safe-bin profile:
<!-- SAFE_BIN_DENIED_FLAGS:START --> [//]: # "SAFE_BIN_DENIED_FLAGS:START"
- `grep`: `--dereference-recursive`, `--directories`, `--exclude-from`, `--file`, `--recursive`, `-R`, `-d`, `-f`, `-r` - `grep`: `--dereference-recursive`, `--directories`, `--exclude-from`, `--file`, `--recursive`, `-R`, `-d`, `-f`, `-r`
- `jq`: `--argfile`, `--from-file`, `--library-path`, `--rawfile`, `--slurpfile`, `-L`, `-f` - `jq`: `--argfile`, `--from-file`, `--library-path`, `--rawfile`, `--slurpfile`, `-L`, `-f`
- `sort`: `--compress-program`, `--files0-from`, `--output`, `--random-source`, `--temporary-directory`, `-T`, `-o` - `sort`: `--compress-program`, `--files0-from`, `--output`, `--random-source`, `--temporary-directory`, `-T`, `-o`
- `wc`: `--files0-from` - `wc`: `--files0-from`
<!-- SAFE_BIN_DENIED_FLAGS:END -->
[//]: # "SAFE_BIN_DENIED_FLAGS:END"
Safe bins also force argv tokens to be treated as **literal text** at execution time (no globbing Safe bins also force argv tokens to be treated as **literal text** at execution time (no globbing
and no `$VARS` expansion) for stdin-only segments, so patterns like `*` or `$HOME/...` cannot be and no `$VARS` expansion) for stdin-only segments, so patterns like `*` or `$HOME/...` cannot be

View File

@ -149,7 +149,11 @@ Lark国际版请使用 https://open.larksuite.com/app并在配置中设
**事件订阅** 页面: **事件订阅** 页面:
1. 选择 **使用长连接接收事件**WebSocket 模式) 1. 选择 **使用长连接接收事件**WebSocket 模式)
2. 添加事件:`im.message.receive_v1`(接收消息) 2. 添加事件:
- `im.message.receive_v1`
- `im.message.reaction.created_v1`
- `im.message.reaction.deleted_v1`
- `application.bot.menu_v6`
⚠️ **注意**:如果网关未启动或渠道未添加,长连接设置将保存失败。 ⚠️ **注意**:如果网关未启动或渠道未添加,长连接设置将保存失败。
@ -435,7 +439,7 @@ openclaw pairing list feishu
| `/reset` | 重置对话会话 | | `/reset` | 重置对话会话 |
| `/model` | 查看/切换模型 | | `/model` | 查看/切换模型 |
> 注意:飞书目前不支持原生命令菜单,命令需要以文本形式发送 飞书机器人菜单建议直接在飞书开放平台的机器人能力页面配置。OpenClaw 当前支持接收 `application.bot.menu_v6` 事件,并把点击事件转换成普通文本命令(例如 `/menu <eventKey>`)继续走现有消息路由,但不通过渠道配置自动创建或同步菜单
## 网关管理命令 ## 网关管理命令
@ -526,7 +530,11 @@ openclaw pairing list feishu
channels: { channels: {
feishu: { feishu: {
streaming: true, // 启用流式卡片输出(默认 true streaming: true, // 启用流式卡片输出(默认 true
blockStreaming: true, // 启用块级流式(默认 true blockStreamingCoalesce: {
enabled: true,
minDelayMs: 50,
maxDelayMs: 250,
},
}, },
}, },
} }
@ -534,6 +542,40 @@ openclaw pairing list feishu
如需禁用流式输出(等待完整回复后一次性发送),可设置 `streaming: false` 如需禁用流式输出(等待完整回复后一次性发送),可设置 `streaming: false`
### 交互式卡片
OpenClaw 默认会在需要时发送 Markdown 卡片;如果你需要完整的 Feishu 原生交互式卡片,也可以显式发送原始 `card` payload。
- 默认路径:文本自动渲染或 Markdown 卡片
- 显式卡片:通过消息动作的 `card` 参数发送原始交互卡片
- 更新卡片:同一消息支持后续 patch/update
卡片按钮回调当前走文本回退路径:
- 若 `action.value.text` 存在,则作为入站文本继续处理
- 若 `action.value.command` 存在,则作为命令文本继续处理
- 其他对象值会序列化为 JSON 文本
这样可以保持与现有消息/命令路由兼容,而不要求下游先理解 Feishu 专有的交互 payload。
### 表情反应
飞书渠道现已完整支持表情反应生命周期:
- 接收 `reaction created`
- 接收 `reaction deleted`
- 主动添加反应
- 主动删除自身反应
- 查询消息上的反应列表
是否把入站反应转成内部消息,可通过 `reactionNotifications` 控制:
| 值 | 行为 |
| ----- | ---------------------------- |
| `off` | 不生成反应通知 |
| `own` | 仅当反应发生在机器人消息上时 |
| `all` | 所有可验证的反应都生成通知 |
### 消息引用 ### 消息引用
在群聊中,机器人的回复可以引用用户发送的原始消息,让对话上下文更加清晰。 在群聊中,机器人的回复可以引用用户发送的原始消息,让对话上下文更加清晰。
@ -653,14 +695,19 @@ openclaw pairing list feishu
| `channels.feishu.accounts.<id>.domain` | 单账号 API 域名覆盖 | `feishu` | | `channels.feishu.accounts.<id>.domain` | 单账号 API 域名覆盖 | `feishu` |
| `channels.feishu.dmPolicy` | 私聊策略 | `pairing` | | `channels.feishu.dmPolicy` | 私聊策略 | `pairing` |
| `channels.feishu.allowFrom` | 私聊白名单open_id 列表) | - | | `channels.feishu.allowFrom` | 私聊白名单open_id 列表) | - |
| `channels.feishu.groupPolicy` | 群组策略 | `open` | | `channels.feishu.groupPolicy` | 群组策略 | `allowlist` |
| `channels.feishu.groupAllowFrom` | 群组白名单 | - | | `channels.feishu.groupAllowFrom` | 群组白名单 | - |
| `channels.feishu.groups.<chat_id>.requireMention` | 是否需要 @提及 | `true` | | `channels.feishu.groups.<chat_id>.requireMention` | 是否需要 @提及 | `true` |
| `channels.feishu.groups.<chat_id>.enabled` | 是否启用该群组 | `true` | | `channels.feishu.groups.<chat_id>.enabled` | 是否启用该群组 | `true` |
| `channels.feishu.replyInThread` | 群聊回复是否进入飞书话题线程 | `disabled` |
| `channels.feishu.groupSessionScope` | 群聊会话隔离粒度 | `group` |
| `channels.feishu.textChunkLimit` | 消息分块大小 | `2000` | | `channels.feishu.textChunkLimit` | 消息分块大小 | `2000` |
| `channels.feishu.mediaMaxMb` | 媒体大小限制 | `30` | | `channels.feishu.mediaMaxMb` | 媒体大小限制 | `30` |
| `channels.feishu.streaming` | 启用流式卡片输出 | `true` | | `channels.feishu.streaming` | 启用流式卡片输出 | `true` |
| `channels.feishu.blockStreaming` | 启用块级流式 | `true` | | `channels.feishu.blockStreamingCoalesce.enabled` | 启用块级流式合并 | `true` |
| `channels.feishu.typingIndicator` | 发送“正在输入”状态 | `true` |
| `channels.feishu.resolveSenderNames` | 拉取发送者名称 | `true` |
| `channels.feishu.reactionNotifications` | 入站反应通知策略 | `own` |
--- ---

View File

@ -57,6 +57,10 @@ export type BlueBubblesAccountConfig = {
allowPrivateNetwork?: boolean; allowPrivateNetwork?: boolean;
/** Per-group configuration keyed by chat GUID or identifier. */ /** Per-group configuration keyed by chat GUID or identifier. */
groups?: Record<string, BlueBubblesGroupConfig>; groups?: Record<string, BlueBubblesGroupConfig>;
/** Channel health monitor overrides for this channel/account. */
healthMonitor?: {
enabled?: boolean;
};
}; };
export type BlueBubblesActionConfig = { export type BlueBubblesActionConfig = {

View File

@ -15,9 +15,12 @@ const {
mockCreateFeishuReplyDispatcher, mockCreateFeishuReplyDispatcher,
mockSendMessageFeishu, mockSendMessageFeishu,
mockGetMessageFeishu, mockGetMessageFeishu,
mockListFeishuThreadMessages,
mockDownloadMessageResourceFeishu, mockDownloadMessageResourceFeishu,
mockCreateFeishuClient, mockCreateFeishuClient,
mockResolveAgentRoute, mockResolveAgentRoute,
mockReadSessionUpdatedAt,
mockResolveStorePath,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
mockCreateFeishuReplyDispatcher: vi.fn(() => ({ mockCreateFeishuReplyDispatcher: vi.fn(() => ({
dispatcher: vi.fn(), dispatcher: vi.fn(),
@ -26,6 +29,7 @@ const {
})), })),
mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }), mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
mockGetMessageFeishu: vi.fn().mockResolvedValue(null), mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
mockListFeishuThreadMessages: vi.fn().mockResolvedValue([]),
mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({ mockDownloadMessageResourceFeishu: vi.fn().mockResolvedValue({
buffer: Buffer.from("video"), buffer: Buffer.from("video"),
contentType: "video/mp4", contentType: "video/mp4",
@ -40,6 +44,8 @@ const {
mainSessionKey: "agent:main:main", mainSessionKey: "agent:main:main",
matchedBy: "default", matchedBy: "default",
})), })),
mockReadSessionUpdatedAt: vi.fn(),
mockResolveStorePath: vi.fn(() => "/tmp/feishu-sessions.json"),
})); }));
vi.mock("./reply-dispatcher.js", () => ({ vi.mock("./reply-dispatcher.js", () => ({
@ -49,6 +55,7 @@ vi.mock("./reply-dispatcher.js", () => ({
vi.mock("./send.js", () => ({ vi.mock("./send.js", () => ({
sendMessageFeishu: mockSendMessageFeishu, sendMessageFeishu: mockSendMessageFeishu,
getMessageFeishu: mockGetMessageFeishu, getMessageFeishu: mockGetMessageFeishu,
listFeishuThreadMessages: mockListFeishuThreadMessages,
})); }));
vi.mock("./media.js", () => ({ vi.mock("./media.js", () => ({
@ -70,11 +77,13 @@ function createRuntimeEnv(): RuntimeEnv {
} }
async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) { async function dispatchMessage(params: { cfg: ClawdbotConfig; event: FeishuMessageEvent }) {
const runtime = createRuntimeEnv();
await handleFeishuMessage({ await handleFeishuMessage({
cfg: params.cfg, cfg: params.cfg,
event: params.event, event: params.event,
runtime: createRuntimeEnv(), runtime,
}); });
return runtime;
} }
describe("buildFeishuAgentBody", () => { describe("buildFeishuAgentBody", () => {
@ -140,6 +149,10 @@ describe("handleFeishuMessage command authorization", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true); mockShouldComputeCommandAuthorized.mockReset().mockReturnValue(true);
mockGetMessageFeishu.mockReset().mockResolvedValue(null);
mockListFeishuThreadMessages.mockReset().mockResolvedValue([]);
mockReadSessionUpdatedAt.mockReturnValue(undefined);
mockResolveStorePath.mockReturnValue("/tmp/feishu-sessions.json");
mockResolveAgentRoute.mockReturnValue({ mockResolveAgentRoute.mockReturnValue({
agentId: "main", agentId: "main",
channel: "feishu", channel: "feishu",
@ -166,6 +179,12 @@ describe("handleFeishuMessage command authorization", () => {
resolveAgentRoute: resolveAgentRoute:
mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"], mockResolveAgentRoute as unknown as PluginRuntime["channel"]["routing"]["resolveAgentRoute"],
}, },
session: {
readSessionUpdatedAt:
mockReadSessionUpdatedAt as unknown as PluginRuntime["channel"]["session"]["readSessionUpdatedAt"],
resolveStorePath:
mockResolveStorePath as unknown as PluginRuntime["channel"]["session"]["resolveStorePath"],
},
reply: { reply: {
resolveEnvelopeFormatOptions: vi.fn( resolveEnvelopeFormatOptions: vi.fn(
() => ({}), () => ({}),
@ -1709,6 +1728,193 @@ describe("handleFeishuMessage command authorization", () => {
); );
}); });
it("bootstraps topic thread context only for a new thread session", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockGetMessageFeishu.mockResolvedValue({
messageId: "om_topic_root",
chatId: "oc-group",
content: "root starter",
contentType: "text",
threadId: "omt_topic_1",
});
mockListFeishuThreadMessages.mockResolvedValue([
{
messageId: "om_bot_reply",
senderId: "app_1",
senderType: "app",
content: "assistant reply",
contentType: "text",
createTime: 1710000000000,
},
{
messageId: "om_follow_up",
senderId: "ou-topic-user",
senderType: "user",
content: "follow-up question",
contentType: "text",
createTime: 1710000001000,
},
]);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groups: {
"oc-group": {
requireMention: false,
groupSessionScope: "group_topic",
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-topic-user" } },
message: {
message_id: "om_topic_followup_existing_session",
root_id: "om_topic_root",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "current turn" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockReadSessionUpdatedAt).toHaveBeenCalledWith({
storePath: "/tmp/feishu-sessions.json",
sessionKey: "agent:main:feishu:dm:ou-attacker",
});
expect(mockListFeishuThreadMessages).toHaveBeenCalledWith(
expect.objectContaining({
rootMessageId: "om_topic_root",
}),
);
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ThreadStarterBody: "root starter",
ThreadHistoryBody: "assistant reply\n\nfollow-up question",
ThreadLabel: "Feishu thread in oc-group",
MessageThreadId: "om_topic_root",
}),
);
});
it("skips topic thread bootstrap when the thread session already exists", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockReadSessionUpdatedAt.mockReturnValue(1710000000000);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groups: {
"oc-group": {
requireMention: false,
groupSessionScope: "group_topic",
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-topic-user" } },
message: {
message_id: "om_topic_followup",
root_id: "om_topic_root",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "current turn" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockGetMessageFeishu).not.toHaveBeenCalled();
expect(mockListFeishuThreadMessages).not.toHaveBeenCalled();
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ThreadStarterBody: undefined,
ThreadHistoryBody: undefined,
ThreadLabel: "Feishu thread in oc-group",
MessageThreadId: "om_topic_root",
}),
);
});
it("keeps sender-scoped thread history when the inbound event and thread history use different sender ids", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
mockGetMessageFeishu.mockResolvedValue({
messageId: "om_topic_root",
chatId: "oc-group",
content: "root starter",
contentType: "text",
threadId: "omt_topic_1",
});
mockListFeishuThreadMessages.mockResolvedValue([
{
messageId: "om_bot_reply",
senderId: "app_1",
senderType: "app",
content: "assistant reply",
contentType: "text",
createTime: 1710000000000,
},
{
messageId: "om_follow_up",
senderId: "user_topic_1",
senderType: "user",
content: "follow-up question",
contentType: "text",
createTime: 1710000001000,
},
]);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
groups: {
"oc-group": {
requireMention: false,
groupSessionScope: "group_topic_sender",
},
},
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: "ou-topic-user",
user_id: "user_topic_1",
},
},
message: {
message_id: "om_topic_followup_mixed_ids",
root_id: "om_topic_root",
chat_id: "oc-group",
chat_type: "group",
message_type: "text",
content: JSON.stringify({ text: "current turn" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockFinalizeInboundContext).toHaveBeenCalledWith(
expect.objectContaining({
ThreadStarterBody: "root starter",
ThreadHistoryBody: "assistant reply\n\nfollow-up question",
ThreadLabel: "Feishu thread in oc-group",
MessageThreadId: "om_topic_root",
}),
);
});
it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => { it("does not dispatch twice for the same image message_id (concurrent dedupe)", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false); mockShouldComputeCommandAuthorized.mockReturnValue(false);

View File

@ -9,6 +9,7 @@ import {
issuePairingChallenge, issuePairingChallenge,
normalizeAgentId, normalizeAgentId,
recordPendingHistoryEntryIfEnabled, recordPendingHistoryEntryIfEnabled,
resolveAgentOutboundIdentity,
resolveOpenProviderRuntimeGroupPolicy, resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy, resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce, warnMissingProviderGroupPolicyFallbackOnce,
@ -29,7 +30,7 @@ import {
import { parsePostContent } from "./post.js"; import { parsePostContent } from "./post.js";
import { createFeishuReplyDispatcher } from "./reply-dispatcher.js"; import { createFeishuReplyDispatcher } from "./reply-dispatcher.js";
import { getFeishuRuntime } from "./runtime.js"; import { getFeishuRuntime } from "./runtime.js";
import { getMessageFeishu, sendMessageFeishu } from "./send.js"; import { getMessageFeishu, listFeishuThreadMessages, sendMessageFeishu } from "./send.js";
import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js"; import type { FeishuMessageContext, FeishuMediaInfo, ResolvedFeishuAccount } from "./types.js";
import type { DynamicAgentCreationConfig } from "./types.js"; import type { DynamicAgentCreationConfig } from "./types.js";
@ -1239,16 +1240,17 @@ export async function handleFeishuMessage(params: {
const mediaPayload = buildAgentMediaPayload(mediaList); const mediaPayload = buildAgentMediaPayload(mediaList);
// Fetch quoted/replied message content if parentId exists // Fetch quoted/replied message content if parentId exists
let quotedMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> = null;
let quotedContent: string | undefined; let quotedContent: string | undefined;
if (ctx.parentId) { if (ctx.parentId) {
try { try {
const quotedMsg = await getMessageFeishu({ quotedMessageInfo = await getMessageFeishu({
cfg, cfg,
messageId: ctx.parentId, messageId: ctx.parentId,
accountId: account.accountId, accountId: account.accountId,
}); });
if (quotedMsg) { if (quotedMessageInfo) {
quotedContent = quotedMsg.content; quotedContent = quotedMessageInfo.content;
log( log(
`feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`, `feishu[${account.accountId}]: fetched quoted message: ${quotedContent?.slice(0, 100)}`,
); );
@ -1258,6 +1260,11 @@ export async function handleFeishuMessage(params: {
} }
} }
const isTopicSessionForThread =
isGroup &&
(groupSession?.groupSessionScope === "group_topic" ||
groupSession?.groupSessionScope === "group_topic_sender");
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg); const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
const messageBody = buildFeishuAgentBody({ const messageBody = buildFeishuAgentBody({
ctx, ctx,
@ -1309,13 +1316,150 @@ export async function handleFeishuMessage(params: {
})) }))
: undefined; : undefined;
const threadContextBySessionKey = new Map<
string,
{
threadStarterBody?: string;
threadHistoryBody?: string;
threadLabel?: string;
}
>();
let rootMessageInfo: Awaited<ReturnType<typeof getMessageFeishu>> | undefined;
let rootMessageFetched = false;
const getRootMessageInfo = async () => {
if (!ctx.rootId) {
return null;
}
if (!rootMessageFetched) {
rootMessageFetched = true;
if (ctx.rootId === ctx.parentId && quotedMessageInfo) {
rootMessageInfo = quotedMessageInfo;
} else {
try {
rootMessageInfo = await getMessageFeishu({
cfg,
messageId: ctx.rootId,
accountId: account.accountId,
});
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch root message: ${String(err)}`);
rootMessageInfo = null;
}
}
}
return rootMessageInfo ?? null;
};
const resolveThreadContextForAgent = async (agentId: string, agentSessionKey: string) => {
const cached = threadContextBySessionKey.get(agentSessionKey);
if (cached) {
return cached;
}
const threadContext: {
threadStarterBody?: string;
threadHistoryBody?: string;
threadLabel?: string;
} = {
threadLabel:
(ctx.rootId || ctx.threadId) && isTopicSessionForThread
? `Feishu thread in ${ctx.chatId}`
: undefined,
};
if (!(ctx.rootId || ctx.threadId) || !isTopicSessionForThread) {
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
}
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, { agentId });
const previousThreadSessionTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: agentSessionKey,
});
if (previousThreadSessionTimestamp) {
log(
`feishu[${account.accountId}]: skipping thread bootstrap for existing session ${agentSessionKey}`,
);
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
}
const rootMsg = await getRootMessageInfo();
let feishuThreadId = ctx.threadId ?? rootMsg?.threadId;
if (feishuThreadId) {
log(`feishu[${account.accountId}]: resolved thread ID: ${feishuThreadId}`);
}
if (!feishuThreadId) {
log(
`feishu[${account.accountId}]: no threadId found for root message ${ctx.rootId ?? "none"}, skipping thread history`,
);
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
}
try {
const threadMessages = await listFeishuThreadMessages({
cfg,
threadId: feishuThreadId,
currentMessageId: ctx.messageId,
rootMessageId: ctx.rootId,
limit: 20,
accountId: account.accountId,
});
const senderScoped = groupSession?.groupSessionScope === "group_topic_sender";
const senderIds = new Set(
[ctx.senderOpenId, senderUserId]
.map((id) => id?.trim())
.filter((id): id is string => id !== undefined && id.length > 0),
);
const relevantMessages =
(senderScoped
? threadMessages.filter(
(msg) =>
msg.senderType === "app" ||
(msg.senderId !== undefined && senderIds.has(msg.senderId.trim())),
)
: threadMessages) ?? [];
const threadStarterBody = rootMsg?.content ?? relevantMessages[0]?.content;
const includeStarterInHistory = Boolean(rootMsg?.content || ctx.rootId);
const historyMessages = includeStarterInHistory
? relevantMessages
: relevantMessages.slice(1);
const historyParts = historyMessages.map((msg) => {
const role = msg.senderType === "app" ? "assistant" : "user";
return core.channel.reply.formatAgentEnvelope({
channel: "Feishu",
from: `${msg.senderId ?? "Unknown"} (${role})`,
timestamp: msg.createTime,
body: msg.content,
envelope: envelopeOptions,
});
});
threadContext.threadStarterBody = threadStarterBody;
threadContext.threadHistoryBody =
historyParts.length > 0 ? historyParts.join("\n\n") : undefined;
log(
`feishu[${account.accountId}]: populated thread bootstrap with starter=${threadStarterBody ? "yes" : "no"} history=${historyMessages.length}`,
);
} catch (err) {
log(`feishu[${account.accountId}]: failed to fetch thread history: ${String(err)}`);
}
threadContextBySessionKey.set(agentSessionKey, threadContext);
return threadContext;
};
// --- Shared context builder for dispatch --- // --- Shared context builder for dispatch ---
const buildCtxPayloadForAgent = ( const buildCtxPayloadForAgent = async (
agentId: string,
agentSessionKey: string, agentSessionKey: string,
agentAccountId: string, agentAccountId: string,
wasMentioned: boolean, wasMentioned: boolean,
) => ) => {
core.channel.reply.finalizeInboundContext({ const threadContext = await resolveThreadContextForAgent(agentId, agentSessionKey);
return core.channel.reply.finalizeInboundContext({
Body: combinedBody, Body: combinedBody,
BodyForAgent: messageBody, BodyForAgent: messageBody,
InboundHistory: inboundHistory, InboundHistory: inboundHistory,
@ -1335,6 +1479,12 @@ export async function handleFeishuMessage(params: {
Surface: "feishu" as const, Surface: "feishu" as const,
MessageSid: ctx.messageId, MessageSid: ctx.messageId,
ReplyToBody: quotedContent ?? undefined, ReplyToBody: quotedContent ?? undefined,
ThreadStarterBody: threadContext.threadStarterBody,
ThreadHistoryBody: threadContext.threadHistoryBody,
ThreadLabel: threadContext.threadLabel,
// Only use rootId (om_* message anchor) — threadId (omt_*) is a container
// ID and would produce invalid reply targets downstream.
MessageThreadId: ctx.rootId && isTopicSessionForThread ? ctx.rootId : undefined,
Timestamp: Date.now(), Timestamp: Date.now(),
WasMentioned: wasMentioned, WasMentioned: wasMentioned,
CommandAuthorized: commandAuthorized, CommandAuthorized: commandAuthorized,
@ -1343,6 +1493,7 @@ export async function handleFeishuMessage(params: {
GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt?.trim() || undefined : undefined, GroupSystemPrompt: isGroup ? groupConfig?.systemPrompt?.trim() || undefined : undefined,
...mediaPayload, ...mediaPayload,
}); });
};
// Parse message create_time (Feishu uses millisecond epoch string). // Parse message create_time (Feishu uses millisecond epoch string).
const messageCreateTimeMs = event.message.create_time const messageCreateTimeMs = event.message.create_time
@ -1402,7 +1553,8 @@ export async function handleFeishuMessage(params: {
} }
const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId); const agentSessionKey = buildBroadcastSessionKey(route.sessionKey, route.agentId, agentId);
const agentCtx = buildCtxPayloadForAgent( const agentCtx = await buildCtxPayloadForAgent(
agentId,
agentSessionKey, agentSessionKey,
route.accountId, route.accountId,
ctx.mentionedBot && agentId === activeAgentId, ctx.mentionedBot && agentId === activeAgentId,
@ -1410,6 +1562,7 @@ export async function handleFeishuMessage(params: {
if (agentId === activeAgentId) { if (agentId === activeAgentId) {
// Active agent: real Feishu dispatcher (responds on Feishu) // Active agent: real Feishu dispatcher (responds on Feishu)
const identity = resolveAgentOutboundIdentity(cfg, agentId);
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({ const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
cfg, cfg,
agentId, agentId,
@ -1422,6 +1575,7 @@ export async function handleFeishuMessage(params: {
threadReply, threadReply,
mentionTargets: ctx.mentionTargets, mentionTargets: ctx.mentionTargets,
accountId: account.accountId, accountId: account.accountId,
identity,
messageCreateTimeMs, messageCreateTimeMs,
}); });
@ -1502,12 +1656,14 @@ export async function handleFeishuMessage(params: {
); );
} else { } else {
// --- Single-agent dispatch (existing behavior) --- // --- Single-agent dispatch (existing behavior) ---
const ctxPayload = buildCtxPayloadForAgent( const ctxPayload = await buildCtxPayloadForAgent(
route.agentId,
route.sessionKey, route.sessionKey,
route.accountId, route.accountId,
ctx.mentionedBot, ctx.mentionedBot,
); );
const identity = resolveAgentOutboundIdentity(cfg, route.agentId);
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({ const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
cfg, cfg,
agentId: route.agentId, agentId: route.agentId,
@ -1520,6 +1676,7 @@ export async function handleFeishuMessage(params: {
threadReply, threadReply,
mentionTargets: ctx.mentionTargets, mentionTargets: ctx.mentionTargets,
accountId: account.accountId, accountId: account.accountId,
identity,
messageCreateTimeMs, messageCreateTimeMs,
}); });

View File

@ -20,6 +20,20 @@ export type FeishuCardActionEvent = {
}; };
}; };
function buildCardActionTextFallback(event: FeishuCardActionEvent): string {
const actionValue = event.action.value;
if (typeof actionValue === "object" && actionValue !== null) {
if ("text" in actionValue && typeof actionValue.text === "string") {
return actionValue.text;
}
if ("command" in actionValue && typeof actionValue.command === "string") {
return actionValue.command;
}
return JSON.stringify(actionValue);
}
return String(actionValue);
}
export async function handleFeishuCardAction(params: { export async function handleFeishuCardAction(params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
event: FeishuCardActionEvent; event: FeishuCardActionEvent;
@ -30,21 +44,7 @@ export async function handleFeishuCardAction(params: {
const { cfg, event, runtime, accountId } = params; const { cfg, event, runtime, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId }); const account = resolveFeishuAccount({ cfg, accountId });
const log = runtime?.log ?? console.log; const log = runtime?.log ?? console.log;
const content = buildCardActionTextFallback(event);
// Extract action value
const actionValue = event.action.value;
let content = "";
if (typeof actionValue === "object" && actionValue !== null) {
if ("text" in actionValue && typeof actionValue.text === "string") {
content = actionValue.text;
} else if ("command" in actionValue && typeof actionValue.command === "string") {
content = actionValue.command;
} else {
content = JSON.stringify(actionValue);
}
} else {
content = String(actionValue);
}
// Construct a synthetic message event // Construct a synthetic message event
const messageEvent: FeishuMessageEvent = { const messageEvent: FeishuMessageEvent = {

View File

@ -2,11 +2,18 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/feishu";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
const probeFeishuMock = vi.hoisted(() => vi.fn()); const probeFeishuMock = vi.hoisted(() => vi.fn());
const listReactionsFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./probe.js", () => ({ vi.mock("./probe.js", () => ({
probeFeishu: probeFeishuMock, probeFeishu: probeFeishuMock,
})); }));
vi.mock("./reactions.js", () => ({
addReactionFeishu: vi.fn(),
listReactionsFeishu: listReactionsFeishuMock,
removeReactionFeishu: vi.fn(),
}));
import { feishuPlugin } from "./channel.js"; import { feishuPlugin } from "./channel.js";
describe("feishuPlugin.status.probeAccount", () => { describe("feishuPlugin.status.probeAccount", () => {
@ -46,3 +53,114 @@ describe("feishuPlugin.status.probeAccount", () => {
expect(result).toMatchObject({ ok: true, appId: "cli_main" }); expect(result).toMatchObject({ ok: true, appId: "cli_main" });
}); });
}); });
describe("feishuPlugin actions", () => {
const cfg = {
channels: {
feishu: {
enabled: true,
appId: "cli_main",
appSecret: "secret_main",
actions: {
reactions: true,
},
},
},
} as OpenClawConfig;
it("does not advertise reactions when disabled via actions config", () => {
const disabledCfg = {
channels: {
feishu: {
enabled: true,
appId: "cli_main",
appSecret: "secret_main",
actions: {
reactions: false,
},
},
},
} as OpenClawConfig;
expect(feishuPlugin.actions?.listActions?.({ cfg: disabledCfg })).toEqual([]);
});
it("advertises reactions when any enabled configured account allows them", () => {
const cfg = {
channels: {
feishu: {
enabled: true,
defaultAccount: "main",
actions: {
reactions: false,
},
accounts: {
main: {
appId: "cli_main",
appSecret: "secret_main",
enabled: true,
actions: {
reactions: false,
},
},
secondary: {
appId: "cli_secondary",
appSecret: "secret_secondary",
enabled: true,
actions: {
reactions: true,
},
},
},
},
},
} as OpenClawConfig;
expect(feishuPlugin.actions?.listActions?.({ cfg })).toEqual(["react", "reactions"]);
});
it("requires clearAll=true before removing all bot reactions", async () => {
await expect(
feishuPlugin.actions?.handleAction?.({
action: "react",
params: { messageId: "om_msg1" },
cfg,
accountId: undefined,
} as never),
).rejects.toThrow(
"Emoji is required to add a Feishu reaction. Set clearAll=true to remove all bot reactions.",
);
});
it("throws for unsupported Feishu send actions without card payload", async () => {
await expect(
feishuPlugin.actions?.handleAction?.({
action: "send",
params: { to: "chat:oc_group_1", message: "hello" },
cfg,
accountId: undefined,
} as never),
).rejects.toThrow('Unsupported Feishu action: "send"');
});
it("allows explicit clearAll=true when removing all bot reactions", async () => {
listReactionsFeishuMock.mockResolvedValueOnce([
{ reactionId: "r1", operatorType: "app" },
{ reactionId: "r2", operatorType: "app" },
]);
const result = await feishuPlugin.actions?.handleAction?.({
action: "react",
params: { messageId: "om_msg1", clearAll: true },
cfg,
accountId: undefined,
} as never);
expect(listReactionsFeishuMock).toHaveBeenCalledWith({
cfg,
messageId: "om_msg1",
accountId: undefined,
});
expect(result?.details).toMatchObject({ ok: true, removed: 2 });
});
});

View File

@ -5,18 +5,23 @@ import {
} from "openclaw/plugin-sdk/compat"; } from "openclaw/plugin-sdk/compat";
import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import { import {
buildChannelConfigSchema,
buildProbeChannelStatusSummary, buildProbeChannelStatusSummary,
createActionGate,
buildRuntimeAccountStatusSnapshot, buildRuntimeAccountStatusSnapshot,
createDefaultChannelRuntimeState, createDefaultChannelRuntimeState,
DEFAULT_ACCOUNT_ID, DEFAULT_ACCOUNT_ID,
PAIRING_APPROVED_MESSAGE, PAIRING_APPROVED_MESSAGE,
} from "openclaw/plugin-sdk/feishu"; } from "openclaw/plugin-sdk/feishu";
import type { ChannelMessageActionName } from "openclaw/plugin-sdk/feishu";
import { import {
resolveFeishuAccount, resolveFeishuAccount,
resolveFeishuCredentials, resolveFeishuCredentials,
listFeishuAccountIds, listFeishuAccountIds,
listEnabledFeishuAccounts,
resolveDefaultFeishuAccountId, resolveDefaultFeishuAccountId,
} from "./accounts.js"; } from "./accounts.js";
import { FeishuConfigSchema } from "./config-schema.js";
import { import {
listFeishuDirectoryPeers, listFeishuDirectoryPeers,
listFeishuDirectoryGroups, listFeishuDirectoryGroups,
@ -27,7 +32,8 @@ import { feishuOnboardingAdapter } from "./onboarding.js";
import { feishuOutbound } from "./outbound.js"; import { feishuOutbound } from "./outbound.js";
import { resolveFeishuGroupToolPolicy } from "./policy.js"; import { resolveFeishuGroupToolPolicy } from "./policy.js";
import { probeFeishu } from "./probe.js"; import { probeFeishu } from "./probe.js";
import { sendMessageFeishu } from "./send.js"; import { addReactionFeishu, listReactionsFeishu, removeReactionFeishu } from "./reactions.js";
import { sendCardFeishu, sendMessageFeishu } from "./send.js";
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js"; import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js"; import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
@ -42,22 +48,6 @@ const meta: ChannelMeta = {
order: 70, order: 70,
}; };
const secretInputJsonSchema = {
oneOf: [
{ type: "string" },
{
type: "object",
additionalProperties: false,
required: ["source", "provider", "id"],
properties: {
source: { type: "string", enum: ["env", "file", "exec"] },
provider: { type: "string", minLength: 1 },
id: { type: "string", minLength: 1 },
},
},
],
} as const;
function setFeishuNamedAccountEnabled( function setFeishuNamedAccountEnabled(
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
accountId: string, accountId: string,
@ -82,6 +72,32 @@ function setFeishuNamedAccountEnabled(
}; };
} }
function isFeishuReactionsActionEnabled(params: {
cfg: ClawdbotConfig;
account: ResolvedFeishuAccount;
}): boolean {
if (!params.account.enabled || !params.account.configured) {
return false;
}
const gate = createActionGate(
(params.account.config.actions ??
(params.cfg.channels?.feishu as { actions?: unknown } | undefined)?.actions) as Record<
string,
boolean | undefined
>,
);
return gate("reactions");
}
function areAnyFeishuReactionActionsEnabled(cfg: ClawdbotConfig): boolean {
for (const account of listEnabledFeishuAccounts(cfg)) {
if (isFeishuReactionsActionEnabled({ cfg, account })) {
return true;
}
}
return false;
}
export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = { export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
id: "feishu", id: "feishu",
meta: { meta: {
@ -120,69 +136,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
stripPatterns: () => ['<at user_id="[^"]*">[^<]*</at>'], stripPatterns: () => ['<at user_id="[^"]*">[^<]*</at>'],
}, },
reload: { configPrefixes: ["channels.feishu"] }, reload: { configPrefixes: ["channels.feishu"] },
configSchema: { configSchema: buildChannelConfigSchema(FeishuConfigSchema),
schema: {
type: "object",
additionalProperties: false,
properties: {
enabled: { type: "boolean" },
defaultAccount: { type: "string" },
appId: { type: "string" },
appSecret: secretInputJsonSchema,
encryptKey: secretInputJsonSchema,
verificationToken: secretInputJsonSchema,
domain: {
oneOf: [
{ type: "string", enum: ["feishu", "lark"] },
{ type: "string", format: "uri", pattern: "^https://" },
],
},
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
webhookPath: { type: "string" },
webhookHost: { type: "string" },
webhookPort: { type: "integer", minimum: 1 },
dmPolicy: { type: "string", enum: ["open", "pairing", "allowlist"] },
allowFrom: { type: "array", items: { oneOf: [{ type: "string" }, { type: "number" }] } },
groupPolicy: { type: "string", enum: ["open", "allowlist", "disabled"] },
groupAllowFrom: {
type: "array",
items: { oneOf: [{ type: "string" }, { type: "number" }] },
},
requireMention: { type: "boolean" },
groupSessionScope: {
type: "string",
enum: ["group", "group_sender", "group_topic", "group_topic_sender"],
},
topicSessionMode: { type: "string", enum: ["disabled", "enabled"] },
replyInThread: { type: "string", enum: ["disabled", "enabled"] },
historyLimit: { type: "integer", minimum: 0 },
dmHistoryLimit: { type: "integer", minimum: 0 },
textChunkLimit: { type: "integer", minimum: 1 },
chunkMode: { type: "string", enum: ["length", "newline"] },
mediaMaxMb: { type: "number", minimum: 0 },
renderMode: { type: "string", enum: ["auto", "raw", "card"] },
accounts: {
type: "object",
additionalProperties: {
type: "object",
properties: {
enabled: { type: "boolean" },
name: { type: "string" },
appId: { type: "string" },
appSecret: secretInputJsonSchema,
encryptKey: secretInputJsonSchema,
verificationToken: secretInputJsonSchema,
domain: { type: "string", enum: ["feishu", "lark"] },
connectionMode: { type: "string", enum: ["websocket", "webhook"] },
webhookHost: { type: "string" },
webhookPath: { type: "string" },
webhookPort: { type: "integer", minimum: 1 },
},
},
},
},
},
},
config: { config: {
listAccountIds: (cfg) => listFeishuAccountIds(cfg), listAccountIds: (cfg) => listFeishuAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }), resolveAccount: (cfg, accountId) => resolveFeishuAccount({ cfg, accountId }),
@ -255,6 +209,172 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
}, },
formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }), formatAllowFrom: ({ allowFrom }) => formatAllowFromLowercase({ allowFrom }),
}, },
actions: {
listActions: ({ cfg }) => {
if (listEnabledFeishuAccounts(cfg).length === 0) {
return [];
}
const actions = new Set<ChannelMessageActionName>();
if (areAnyFeishuReactionActionsEnabled(cfg)) {
actions.add("react");
actions.add("reactions");
}
return Array.from(actions);
},
supportsCards: ({ cfg }) => {
return (
cfg.channels?.feishu?.enabled !== false &&
Boolean(resolveFeishuCredentials(cfg.channels?.feishu as FeishuConfig | undefined))
);
},
handleAction: async (ctx) => {
const account = resolveFeishuAccount({ cfg: ctx.cfg, accountId: ctx.accountId ?? undefined });
if (
(ctx.action === "react" || ctx.action === "reactions") &&
!isFeishuReactionsActionEnabled({ cfg: ctx.cfg, account })
) {
throw new Error("Feishu reactions are disabled via actions.reactions.");
}
if (ctx.action === "send" && ctx.params.card) {
const card = ctx.params.card as Record<string, unknown>;
const to =
typeof ctx.params.to === "string"
? ctx.params.to.trim()
: typeof ctx.params.target === "string"
? ctx.params.target.trim()
: "";
if (!to) {
return {
isError: true,
content: [{ type: "text" as const, text: "Feishu card send requires a target (to)." }],
details: { error: "Feishu card send requires a target (to)." },
};
}
const replyToMessageId =
typeof ctx.params.replyTo === "string"
? ctx.params.replyTo.trim() || undefined
: undefined;
const result = await sendCardFeishu({
cfg: ctx.cfg,
to,
card,
accountId: ctx.accountId ?? undefined,
replyToMessageId,
});
return {
content: [
{
type: "text" as const,
text: JSON.stringify({ ok: true, channel: "feishu", ...result }),
},
],
details: { ok: true, channel: "feishu", ...result },
};
}
if (ctx.action === "react") {
const messageId =
(typeof ctx.params.messageId === "string" && ctx.params.messageId.trim()) ||
(typeof ctx.params.message_id === "string" && ctx.params.message_id.trim()) ||
undefined;
if (!messageId) {
throw new Error("Feishu reaction requires messageId.");
}
const emoji = typeof ctx.params.emoji === "string" ? ctx.params.emoji.trim() : "";
const remove = ctx.params.remove === true;
const clearAll = ctx.params.clearAll === true;
if (remove) {
if (!emoji) {
throw new Error("Emoji is required to remove a Feishu reaction.");
}
const matches = await listReactionsFeishu({
cfg: ctx.cfg,
messageId,
emojiType: emoji,
accountId: ctx.accountId ?? undefined,
});
const ownReaction = matches.find((entry) => entry.operatorType === "app");
if (!ownReaction) {
return {
content: [
{ type: "text" as const, text: JSON.stringify({ ok: true, removed: null }) },
],
details: { ok: true, removed: null },
};
}
await removeReactionFeishu({
cfg: ctx.cfg,
messageId,
reactionId: ownReaction.reactionId,
accountId: ctx.accountId ?? undefined,
});
return {
content: [
{ type: "text" as const, text: JSON.stringify({ ok: true, removed: emoji }) },
],
details: { ok: true, removed: emoji },
};
}
if (!emoji) {
if (!clearAll) {
throw new Error(
"Emoji is required to add a Feishu reaction. Set clearAll=true to remove all bot reactions.",
);
}
const reactions = await listReactionsFeishu({
cfg: ctx.cfg,
messageId,
accountId: ctx.accountId ?? undefined,
});
let removed = 0;
for (const reaction of reactions.filter((entry) => entry.operatorType === "app")) {
await removeReactionFeishu({
cfg: ctx.cfg,
messageId,
reactionId: reaction.reactionId,
accountId: ctx.accountId ?? undefined,
});
removed += 1;
}
return {
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, removed }) }],
details: { ok: true, removed },
};
}
await addReactionFeishu({
cfg: ctx.cfg,
messageId,
emojiType: emoji,
accountId: ctx.accountId ?? undefined,
});
return {
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, added: emoji }) }],
details: { ok: true, added: emoji },
};
}
if (ctx.action === "reactions") {
const messageId =
(typeof ctx.params.messageId === "string" && ctx.params.messageId.trim()) ||
(typeof ctx.params.message_id === "string" && ctx.params.message_id.trim()) ||
undefined;
if (!messageId) {
throw new Error("Feishu reactions lookup requires messageId.");
}
const reactions = await listReactionsFeishu({
cfg: ctx.cfg,
messageId,
accountId: ctx.accountId ?? undefined,
});
return {
content: [{ type: "text" as const, text: JSON.stringify({ ok: true, reactions }) }],
details: { ok: true, reactions },
};
}
throw new Error(`Unsupported Feishu action: "${String(ctx.action)}"`);
},
},
security: { security: {
collectWarnings: ({ cfg, accountId }) => { collectWarnings: ({ cfg, accountId }) => {
const account = resolveFeishuAccount({ cfg, accountId }); const account = resolveFeishuAccount({ cfg, accountId });

View File

@ -217,6 +217,26 @@ describe("FeishuConfigSchema optimization flags", () => {
}); });
}); });
describe("FeishuConfigSchema actions", () => {
it("accepts top-level reactions action gate", () => {
const result = FeishuConfigSchema.parse({
actions: { reactions: false },
});
expect(result.actions?.reactions).toBe(false);
});
it("accepts account-level reactions action gate", () => {
const result = FeishuConfigSchema.parse({
accounts: {
main: {
actions: { reactions: false },
},
},
});
expect(result.accounts?.main?.actions?.reactions).toBe(false);
});
});
describe("FeishuConfigSchema defaultAccount", () => { describe("FeishuConfigSchema defaultAccount", () => {
it("accepts defaultAccount when it matches an account key", () => { it("accepts defaultAccount when it matches an account key", () => {
const result = FeishuConfigSchema.safeParse({ const result = FeishuConfigSchema.safeParse({

View File

@ -3,6 +3,13 @@ import { z } from "zod";
export { z }; export { z };
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js"; import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
const ChannelActionsSchema = z
.object({
reactions: z.boolean().optional(),
})
.strict()
.optional();
const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]); const DmPolicySchema = z.enum(["open", "pairing", "allowlist"]);
const GroupPolicySchema = z.union([ const GroupPolicySchema = z.union([
z.enum(["open", "allowlist", "disabled"]), z.enum(["open", "allowlist", "disabled"]),
@ -170,6 +177,7 @@ const FeishuSharedConfigShape = {
renderMode: RenderModeSchema, renderMode: RenderModeSchema,
streaming: StreamingModeSchema, streaming: StreamingModeSchema,
tools: FeishuToolsConfigSchema, tools: FeishuToolsConfigSchema,
actions: ChannelActionsSchema,
replyInThread: ReplyInThreadSchema, replyInThread: ReplyInThreadSchema,
reactionNotifications: ReactionNotificationModeSchema, reactionNotifications: ReactionNotificationModeSchema,
typingIndicator: z.boolean().optional(), typingIndicator: z.boolean().optional(),

View File

@ -38,6 +38,10 @@ export type FeishuReactionCreatedEvent = {
action_time?: string; action_time?: string;
}; };
export type FeishuReactionDeletedEvent = FeishuReactionCreatedEvent & {
reaction_id?: string;
};
type ResolveReactionSyntheticEventParams = { type ResolveReactionSyntheticEventParams = {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
accountId: string; accountId: string;
@ -47,6 +51,7 @@ type ResolveReactionSyntheticEventParams = {
verificationTimeoutMs?: number; verificationTimeoutMs?: number;
logger?: (message: string) => void; logger?: (message: string) => void;
uuid?: () => string; uuid?: () => string;
action?: "created" | "deleted";
}; };
export async function resolveReactionSyntheticEvent( export async function resolveReactionSyntheticEvent(
@ -61,6 +66,7 @@ export async function resolveReactionSyntheticEvent(
verificationTimeoutMs = FEISHU_REACTION_VERIFY_TIMEOUT_MS, verificationTimeoutMs = FEISHU_REACTION_VERIFY_TIMEOUT_MS,
logger, logger,
uuid = () => crypto.randomUUID(), uuid = () => crypto.randomUUID(),
action = "created",
} = params; } = params;
const emoji = event.reaction_type?.emoji_type; const emoji = event.reaction_type?.emoji_type;
@ -129,7 +135,10 @@ export async function resolveReactionSyntheticEvent(
chat_type: syntheticChatType, chat_type: syntheticChatType,
message_type: "text", message_type: "text",
content: JSON.stringify({ content: JSON.stringify({
text: `[reacted with ${emoji} to message ${messageId}]`, text:
action === "deleted"
? `[removed reaction ${emoji} from message ${messageId}]`
: `[reacted with ${emoji} to message ${messageId}]`,
}), }),
}, },
}; };
@ -253,6 +262,19 @@ function registerEventHandlers(
const log = runtime?.log ?? console.log; const log = runtime?.log ?? console.log;
const error = runtime?.error ?? console.error; const error = runtime?.error ?? console.error;
const enqueue = createChatQueue(); const enqueue = createChatQueue();
const runFeishuHandler = async (params: { task: () => Promise<void>; errorMessage: string }) => {
if (fireAndForget) {
void params.task().catch((err) => {
error(`${params.errorMessage}: ${String(err)}`);
});
return;
}
try {
await params.task();
} catch (err) {
error(`${params.errorMessage}: ${String(err)}`);
}
};
const dispatchFeishuMessage = async (event: FeishuMessageEvent) => { const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
const chatId = event.message.chat_id?.trim() || "unknown"; const chatId = event.message.chat_id?.trim() || "unknown";
const task = () => const task = () =>
@ -428,7 +450,9 @@ function registerEventHandlers(
} }
}, },
"im.message.reaction.created_v1": async (data) => { "im.message.reaction.created_v1": async (data) => {
const processReaction = async () => { await runFeishuHandler({
errorMessage: `feishu[${accountId}]: error handling reaction event`,
task: async () => {
const event = data as FeishuReactionCreatedEvent; const event = data as FeishuReactionCreatedEvent;
const myBotId = botOpenIds.get(accountId); const myBotId = botOpenIds.get(accountId);
const syntheticEvent = await resolveReactionSyntheticEvent({ const syntheticEvent = await resolveReactionSyntheticEvent({
@ -450,31 +474,94 @@ function registerEventHandlers(
chatHistories, chatHistories,
accountId, accountId,
}); });
await promise;
},
});
},
"im.message.reaction.deleted_v1": async (data) => {
await runFeishuHandler({
errorMessage: `feishu[${accountId}]: error handling reaction removal event`,
task: async () => {
const event = data as FeishuReactionDeletedEvent;
const myBotId = botOpenIds.get(accountId);
const syntheticEvent = await resolveReactionSyntheticEvent({
cfg,
accountId,
event,
botOpenId: myBotId,
logger: log,
action: "deleted",
});
if (!syntheticEvent) {
return;
}
const promise = handleFeishuMessage({
cfg,
event: syntheticEvent,
botOpenId: myBotId,
botName: botNames.get(accountId),
runtime,
chatHistories,
accountId,
});
await promise;
},
});
},
"application.bot.menu_v6": async (data) => {
try {
const event = data as {
event_key?: string;
timestamp?: number;
operator?: {
operator_name?: string;
operator_id?: { open_id?: string; user_id?: string; union_id?: string };
};
};
const operatorOpenId = event.operator?.operator_id?.open_id?.trim();
const eventKey = event.event_key?.trim();
if (!operatorOpenId || !eventKey) {
return;
}
const syntheticEvent: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: operatorOpenId,
user_id: event.operator?.operator_id?.user_id,
union_id: event.operator?.operator_id?.union_id,
},
sender_type: "user",
},
message: {
message_id: `bot-menu:${eventKey}:${event.timestamp ?? Date.now()}`,
chat_id: `p2p:${operatorOpenId}`,
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({
text: `/menu ${eventKey}`,
}),
},
};
const promise = handleFeishuMessage({
cfg,
event: syntheticEvent,
botOpenId: botOpenIds.get(accountId),
botName: botNames.get(accountId),
runtime,
chatHistories,
accountId,
});
if (fireAndForget) { if (fireAndForget) {
promise.catch((err) => { promise.catch((err) => {
error(`feishu[${accountId}]: error handling reaction: ${String(err)}`); error(`feishu[${accountId}]: error handling bot menu event: ${String(err)}`);
}); });
return; return;
} }
await promise; await promise;
};
if (fireAndForget) {
void processReaction().catch((err) => {
error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`);
});
return;
}
try {
await processReaction();
} catch (err) { } catch (err) {
error(`feishu[${accountId}]: error handling reaction event: ${String(err)}`); error(`feishu[${accountId}]: error handling bot menu event: ${String(err)}`);
} }
}, },
"im.message.reaction.deleted_v1": async () => {
// Ignore reaction removals
},
"card.action.trigger": async (data: unknown) => { "card.action.trigger": async (data: unknown) => {
try { try {
const event = data as unknown as FeishuCardActionEvent; const event = data as unknown as FeishuCardActionEvent;

View File

@ -0,0 +1,67 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import { describe, expect, it } from "vitest";
import {
resolveReactionSyntheticEvent,
type FeishuReactionCreatedEvent,
} from "./monitor.account.js";
const cfg = {} as ClawdbotConfig;
function makeReactionEvent(
overrides: Partial<FeishuReactionCreatedEvent> = {},
): FeishuReactionCreatedEvent {
return {
message_id: "om_msg1",
reaction_type: { emoji_type: "THUMBSUP" },
operator_type: "user",
user_id: { open_id: "ou_user1" },
...overrides,
};
}
describe("Feishu reaction lifecycle", () => {
it("builds a created synthetic interaction payload", async () => {
const result = await resolveReactionSyntheticEvent({
cfg,
accountId: "default",
event: makeReactionEvent(),
botOpenId: "ou_bot",
fetchMessage: async () => ({
messageId: "om_msg1",
chatId: "oc_group_1",
chatType: "group",
senderOpenId: "ou_bot",
senderType: "app",
content: "hello",
contentType: "text",
}),
uuid: () => "fixed-uuid",
});
expect(result?.message.content).toBe('{"text":"[reacted with THUMBSUP to message om_msg1]"}');
});
it("builds a deleted synthetic interaction payload", async () => {
const result = await resolveReactionSyntheticEvent({
cfg,
accountId: "default",
event: makeReactionEvent(),
botOpenId: "ou_bot",
fetchMessage: async () => ({
messageId: "om_msg1",
chatId: "oc_group_1",
chatType: "group",
senderOpenId: "ou_bot",
senderType: "app",
content: "hello",
contentType: "text",
}),
uuid: () => "fixed-uuid",
action: "deleted",
});
expect(result?.message.content).toBe(
'{"text":"[removed reaction THUMBSUP from message om_msg1]"}',
);
});
});

View File

@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const sendMediaFeishuMock = vi.hoisted(() => vi.fn()); const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn()); const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn()); const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./media.js", () => ({ vi.mock("./media.js", () => ({
sendMediaFeishu: sendMediaFeishuMock, sendMediaFeishu: sendMediaFeishuMock,
@ -14,6 +15,7 @@ vi.mock("./media.js", () => ({
vi.mock("./send.js", () => ({ vi.mock("./send.js", () => ({
sendMessageFeishu: sendMessageFeishuMock, sendMessageFeishu: sendMessageFeishuMock,
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock, sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
sendStructuredCardFeishu: sendStructuredCardFeishuMock,
})); }));
vi.mock("./runtime.js", () => ({ vi.mock("./runtime.js", () => ({
@ -33,6 +35,7 @@ function resetOutboundMocks() {
vi.clearAllMocks(); vi.clearAllMocks();
sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" }); sendMessageFeishuMock.mockResolvedValue({ messageId: "text_msg" });
sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" }); sendMarkdownCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
sendStructuredCardFeishuMock.mockResolvedValue({ messageId: "card_msg" });
sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" }); sendMediaFeishuMock.mockResolvedValue({ messageId: "media_msg" });
} }
@ -132,7 +135,7 @@ describe("feishuOutbound.sendText local-image auto-convert", () => {
accountId: "main", accountId: "main",
}); });
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith( expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
to: "chat_1", to: "chat_1",
text: "| a | b |\n| - | - |", text: "| a | b |\n| - | - |",
@ -207,7 +210,7 @@ describe("feishuOutbound.sendText replyToId forwarding", () => {
); );
}); });
it("forwards replyToId to sendMarkdownCardFeishu when renderMode=card", async () => { it("forwards replyToId to sendStructuredCardFeishu when renderMode=card", async () => {
await sendText({ await sendText({
cfg: { cfg: {
channels: { channels: {
@ -222,7 +225,7 @@ describe("feishuOutbound.sendText replyToId forwarding", () => {
accountId: "main", accountId: "main",
}); });
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith( expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
replyToMessageId: "om_reply_target", replyToMessageId: "om_reply_target",
}), }),

View File

@ -4,7 +4,7 @@ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/feishu";
import { resolveFeishuAccount } from "./accounts.js"; import { resolveFeishuAccount } from "./accounts.js";
import { sendMediaFeishu } from "./media.js"; import { sendMediaFeishu } from "./media.js";
import { getFeishuRuntime } from "./runtime.js"; import { getFeishuRuntime } from "./runtime.js";
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js"; import { sendMarkdownCardFeishu, sendMessageFeishu, sendStructuredCardFeishu } from "./send.js";
function normalizePossibleLocalImagePath(text: string | undefined): string | null { function normalizePossibleLocalImagePath(text: string | undefined): string | null {
const raw = text?.trim(); const raw = text?.trim();
@ -81,7 +81,16 @@ export const feishuOutbound: ChannelOutboundAdapter = {
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit), chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown", chunkerMode: "markdown",
textChunkLimit: 4000, textChunkLimit: 4000,
sendText: async ({ cfg, to, text, accountId, replyToId, threadId, mediaLocalRoots }) => { sendText: async ({
cfg,
to,
text,
accountId,
replyToId,
threadId,
mediaLocalRoots,
identity,
}) => {
const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId }); const replyToMessageId = resolveReplyToMessageId({ replyToId, threadId });
// Scheme A compatibility shim: // Scheme A compatibility shim:
// when upstream accidentally returns a local image path as plain text, // when upstream accidentally returns a local image path as plain text,
@ -104,6 +113,29 @@ export const feishuOutbound: ChannelOutboundAdapter = {
} }
} }
const account = resolveFeishuAccount({ cfg, accountId: accountId ?? undefined });
const renderMode = account.config?.renderMode ?? "auto";
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
if (useCard) {
const header = identity
? {
title: identity.emoji
? `${identity.emoji} ${identity.name ?? ""}`.trim()
: (identity.name ?? ""),
template: "blue" as const,
}
: undefined;
const result = await sendStructuredCardFeishu({
cfg,
to,
text,
replyToMessageId,
replyInThread: threadId != null && !replyToId,
accountId: accountId ?? undefined,
header: header?.title ? header : undefined,
});
return { channel: "feishu", ...result };
}
const result = await sendOutboundText({ const result = await sendOutboundText({
cfg, cfg,
to, to,

View File

@ -4,6 +4,7 @@ const resolveFeishuAccountMock = vi.hoisted(() => vi.fn());
const getFeishuRuntimeMock = vi.hoisted(() => vi.fn()); const getFeishuRuntimeMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn()); const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn()); const sendMarkdownCardFeishuMock = vi.hoisted(() => vi.fn());
const sendStructuredCardFeishuMock = vi.hoisted(() => vi.fn());
const sendMediaFeishuMock = vi.hoisted(() => vi.fn()); const sendMediaFeishuMock = vi.hoisted(() => vi.fn());
const createFeishuClientMock = vi.hoisted(() => vi.fn()); const createFeishuClientMock = vi.hoisted(() => vi.fn());
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn()); const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
@ -17,6 +18,7 @@ vi.mock("./runtime.js", () => ({ getFeishuRuntime: getFeishuRuntimeMock }));
vi.mock("./send.js", () => ({ vi.mock("./send.js", () => ({
sendMessageFeishu: sendMessageFeishuMock, sendMessageFeishu: sendMessageFeishuMock,
sendMarkdownCardFeishu: sendMarkdownCardFeishuMock, sendMarkdownCardFeishu: sendMarkdownCardFeishuMock,
sendStructuredCardFeishu: sendStructuredCardFeishuMock,
})); }));
vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock })); vi.mock("./media.js", () => ({ sendMediaFeishu: sendMediaFeishuMock }));
vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock })); vi.mock("./client.js", () => ({ createFeishuClient: createFeishuClientMock }));
@ -56,6 +58,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
vi.clearAllMocks(); vi.clearAllMocks();
streamingInstances.length = 0; streamingInstances.length = 0;
sendMediaFeishuMock.mockResolvedValue(undefined); sendMediaFeishuMock.mockResolvedValue(undefined);
sendStructuredCardFeishuMock.mockResolvedValue(undefined);
resolveFeishuAccountMock.mockReturnValue({ resolveFeishuAccountMock.mockReturnValue({
accountId: "main", accountId: "main",
@ -255,11 +258,17 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(streamingInstances).toHaveLength(1); expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1); expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", { expect(streamingInstances[0].start).toHaveBeenCalledWith(
"oc_chat",
"chat_id",
expect.objectContaining({
replyToMessageId: undefined, replyToMessageId: undefined,
replyInThread: undefined, replyInThread: undefined,
rootId: "om_root_topic", rootId: "om_root_topic",
}); header: { title: "agent", template: "blue" },
note: "Agent: agent",
}),
);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
@ -275,7 +284,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(streamingInstances).toHaveLength(1); expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].start).toHaveBeenCalledTimes(1); expect(streamingInstances[0].start).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```"); expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\npartial answer\n```", {
note: "Agent: agent",
});
}); });
it("delivers distinct final payloads after streaming close", async () => { it("delivers distinct final payloads after streaming close", async () => {
@ -287,9 +298,16 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(streamingInstances).toHaveLength(2); expect(streamingInstances).toHaveLength(2);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n完整回复第一段\n```"); expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n完整回复第一段\n```", {
note: "Agent: agent",
});
expect(streamingInstances[1].close).toHaveBeenCalledTimes(1); expect(streamingInstances[1].close).toHaveBeenCalledTimes(1);
expect(streamingInstances[1].close).toHaveBeenCalledWith("```md\n完整回复第一段 + 第二段\n```"); expect(streamingInstances[1].close).toHaveBeenCalledWith(
"```md\n完整回复第一段 + 第二段\n```",
{
note: "Agent: agent",
},
);
expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
}); });
@ -303,7 +321,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(streamingInstances).toHaveLength(1); expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```"); expect(streamingInstances[0].close).toHaveBeenCalledWith("```md\n同一条回复\n```", {
note: "Agent: agent",
});
expect(sendMessageFeishuMock).not.toHaveBeenCalled(); expect(sendMessageFeishuMock).not.toHaveBeenCalled();
expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled(); expect(sendMarkdownCardFeishuMock).not.toHaveBeenCalled();
}); });
@ -367,7 +387,9 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
expect(streamingInstances).toHaveLength(1); expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1); expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world"); expect(streamingInstances[0].close).toHaveBeenCalledWith("hellolo world", {
note: "Agent: agent",
});
}); });
it("sends media-only payloads as attachments", async () => { it("sends media-only payloads as attachments", async () => {
@ -436,7 +458,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
); );
}); });
it("passes replyInThread to sendMarkdownCardFeishu for card text", async () => { it("passes replyInThread to sendStructuredCardFeishu for card text", async () => {
resolveFeishuAccountMock.mockReturnValue({ resolveFeishuAccountMock.mockReturnValue({
accountId: "main", accountId: "main",
appId: "app_id", appId: "app_id",
@ -454,7 +476,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
}); });
await options.deliver({ text: "card text" }, { kind: "final" }); await options.deliver({ text: "card text" }, { kind: "final" });
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith( expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
replyToMessageId: "om_msg", replyToMessageId: "om_msg",
replyInThread: true, replyInThread: true,
@ -462,6 +484,126 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
); );
}); });
it("streams reasoning content as blockquote before answer", async () => {
const { result, options } = createDispatcherHarness({
runtime: createRuntimeLogger(),
});
await options.onReplyStart?.();
// Core agent sends pre-formatted text from formatReasoningMessage
result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_thinking step 1_" });
result.replyOptions.onReasoningStream?.({
text: "Reasoning:\n_thinking step 1_\n_step 2_",
});
result.replyOptions.onPartialReply?.({ text: "answer part" });
result.replyOptions.onReasoningEnd?.();
await options.deliver({ text: "answer part final" }, { kind: "final" });
expect(streamingInstances).toHaveLength(1);
const updateCalls = streamingInstances[0].update.mock.calls.map((c: unknown[]) => c[0]);
const reasoningUpdate = updateCalls.find((c: string) => c.includes("Thinking"));
expect(reasoningUpdate).toContain("> 💭 **Thinking**");
// formatReasoningPrefix strips "Reasoning:" prefix and italic markers
expect(reasoningUpdate).toContain("> thinking step");
expect(reasoningUpdate).not.toContain("Reasoning:");
expect(reasoningUpdate).not.toMatch(/> _.*_/);
const combinedUpdate = updateCalls.find(
(c: string) => c.includes("Thinking") && c.includes("---"),
);
expect(combinedUpdate).toBeDefined();
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
const closeArg = streamingInstances[0].close.mock.calls[0][0] as string;
expect(closeArg).toContain("> 💭 **Thinking**");
expect(closeArg).toContain("---");
expect(closeArg).toContain("answer part final");
});
it("provides onReasoningStream and onReasoningEnd when streaming is enabled", () => {
const { result } = createDispatcherHarness({
runtime: createRuntimeLogger(),
});
expect(result.replyOptions.onReasoningStream).toBeTypeOf("function");
expect(result.replyOptions.onReasoningEnd).toBeTypeOf("function");
});
it("omits reasoning callbacks when streaming is disabled", () => {
resolveFeishuAccountMock.mockReturnValue({
accountId: "main",
appId: "app_id",
appSecret: "app_secret",
domain: "feishu",
config: {
renderMode: "auto",
streaming: false,
},
});
const { result } = createDispatcherHarness({
runtime: createRuntimeLogger(),
});
expect(result.replyOptions.onReasoningStream).toBeUndefined();
expect(result.replyOptions.onReasoningEnd).toBeUndefined();
});
it("renders reasoning-only card when no answer text arrives", async () => {
const { result, options } = createDispatcherHarness({
runtime: createRuntimeLogger(),
});
await options.onReplyStart?.();
result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_deep thought_" });
result.replyOptions.onReasoningEnd?.();
await options.onIdle?.();
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
const closeArg = streamingInstances[0].close.mock.calls[0][0] as string;
expect(closeArg).toContain("> 💭 **Thinking**");
expect(closeArg).toContain("> deep thought");
expect(closeArg).not.toContain("Reasoning:");
expect(closeArg).not.toContain("---");
});
it("ignores empty reasoning payloads", async () => {
const { result, options } = createDispatcherHarness({
runtime: createRuntimeLogger(),
});
await options.onReplyStart?.();
result.replyOptions.onReasoningStream?.({ text: "" });
result.replyOptions.onPartialReply?.({ text: "```ts\ncode\n```" });
await options.deliver({ text: "```ts\ncode\n```" }, { kind: "final" });
expect(streamingInstances).toHaveLength(1);
const closeArg = streamingInstances[0].close.mock.calls[0][0] as string;
expect(closeArg).not.toContain("Thinking");
expect(closeArg).toBe("```ts\ncode\n```");
});
it("deduplicates final text by raw answer payload, not combined card text", async () => {
const { result, options } = createDispatcherHarness({
runtime: createRuntimeLogger(),
});
await options.onReplyStart?.();
result.replyOptions.onReasoningStream?.({ text: "Reasoning:\n_thought_" });
result.replyOptions.onReasoningEnd?.();
await options.deliver({ text: "```ts\nfinal answer\n```" }, { kind: "final" });
expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].close).toHaveBeenCalledTimes(1);
// Deliver the same raw answer text again — should be deduped
await options.deliver({ text: "```ts\nfinal answer\n```" }, { kind: "final" });
// No second streaming session since the raw answer text matches
expect(streamingInstances).toHaveLength(1);
});
it("passes replyToMessageId and replyInThread to streaming.start()", async () => { it("passes replyToMessageId and replyInThread to streaming.start()", async () => {
const { options } = createDispatcherHarness({ const { options } = createDispatcherHarness({
runtime: createRuntimeLogger(), runtime: createRuntimeLogger(),
@ -471,10 +613,16 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" }); await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
expect(streamingInstances).toHaveLength(1); expect(streamingInstances).toHaveLength(1);
expect(streamingInstances[0].start).toHaveBeenCalledWith("oc_chat", "chat_id", { expect(streamingInstances[0].start).toHaveBeenCalledWith(
"oc_chat",
"chat_id",
expect.objectContaining({
replyToMessageId: "om_msg", replyToMessageId: "om_msg",
replyInThread: true, replyInThread: true,
}); header: { title: "agent", template: "blue" },
note: "Agent: agent",
}),
);
}); });
it("disables streaming for thread replies and keeps reply metadata", async () => { it("disables streaming for thread replies and keeps reply metadata", async () => {
@ -488,7 +636,7 @@ describe("createFeishuReplyDispatcher streaming behavior", () => {
await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" }); await options.deliver({ text: "```ts\nconst x = 1\n```" }, { kind: "final" });
expect(streamingInstances).toHaveLength(0); expect(streamingInstances).toHaveLength(0);
expect(sendMarkdownCardFeishuMock).toHaveBeenCalledWith( expect(sendStructuredCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
replyToMessageId: "om_msg", replyToMessageId: "om_msg",
replyInThread: true, replyInThread: true,

View File

@ -3,6 +3,7 @@ import {
createTypingCallbacks, createTypingCallbacks,
logTypingFailure, logTypingFailure,
type ClawdbotConfig, type ClawdbotConfig,
type OutboundIdentity,
type ReplyPayload, type ReplyPayload,
type RuntimeEnv, type RuntimeEnv,
} from "openclaw/plugin-sdk/feishu"; } from "openclaw/plugin-sdk/feishu";
@ -12,7 +13,12 @@ import { sendMediaFeishu } from "./media.js";
import type { MentionTarget } from "./mention.js"; import type { MentionTarget } from "./mention.js";
import { buildMentionedCardContent } from "./mention.js"; import { buildMentionedCardContent } from "./mention.js";
import { getFeishuRuntime } from "./runtime.js"; import { getFeishuRuntime } from "./runtime.js";
import { sendMarkdownCardFeishu, sendMessageFeishu } from "./send.js"; import {
sendMarkdownCardFeishu,
sendMessageFeishu,
sendStructuredCardFeishu,
type CardHeaderConfig,
} from "./send.js";
import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js"; import { FeishuStreamingSession, mergeStreamingText } from "./streaming-card.js";
import { resolveReceiveIdType } from "./targets.js"; import { resolveReceiveIdType } from "./targets.js";
import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js"; import { addTypingIndicator, removeTypingIndicator, type TypingIndicatorState } from "./typing.js";
@ -36,6 +42,36 @@ function normalizeEpochMs(timestamp: number | undefined): number | undefined {
return timestamp < MS_EPOCH_MIN ? timestamp * 1000 : timestamp; return timestamp < MS_EPOCH_MIN ? timestamp * 1000 : timestamp;
} }
/** Build a card header from agent identity config. */
function resolveCardHeader(
agentId: string,
identity: OutboundIdentity | undefined,
): CardHeaderConfig {
const name = identity?.name?.trim() || agentId;
const emoji = identity?.emoji?.trim();
return {
title: emoji ? `${emoji} ${name}` : name,
template: identity?.theme ?? "blue",
};
}
/** Build a card note footer from agent identity and model context. */
function resolveCardNote(
agentId: string,
identity: OutboundIdentity | undefined,
prefixCtx: { model?: string; provider?: string },
): string {
const name = identity?.name?.trim() || agentId;
const parts: string[] = [`Agent: ${name}`];
if (prefixCtx.model) {
parts.push(`Model: ${prefixCtx.model}`);
}
if (prefixCtx.provider) {
parts.push(`Provider: ${prefixCtx.provider}`);
}
return parts.join(" | ");
}
export type CreateFeishuReplyDispatcherParams = { export type CreateFeishuReplyDispatcherParams = {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
agentId: string; agentId: string;
@ -50,6 +86,7 @@ export type CreateFeishuReplyDispatcherParams = {
rootId?: string; rootId?: string;
mentionTargets?: MentionTarget[]; mentionTargets?: MentionTarget[];
accountId?: string; accountId?: string;
identity?: OutboundIdentity;
/** Epoch ms when the inbound message was created. Used to suppress typing /** Epoch ms when the inbound message was created. Used to suppress typing
* indicators on old/replayed messages after context compaction (#30418). */ * indicators on old/replayed messages after context compaction (#30418). */
messageCreateTimeMs?: number; messageCreateTimeMs?: number;
@ -68,6 +105,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
rootId, rootId,
mentionTargets, mentionTargets,
accountId, accountId,
identity,
} = params; } = params;
const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId; const sendReplyToMessageId = skipReplyToInMessages ? undefined : replyToMessageId;
const threadReplyMode = threadReply === true; const threadReplyMode = threadReply === true;
@ -143,11 +181,39 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
let streaming: FeishuStreamingSession | null = null; let streaming: FeishuStreamingSession | null = null;
let streamText = ""; let streamText = "";
let lastPartial = ""; let lastPartial = "";
let reasoningText = "";
const deliveredFinalTexts = new Set<string>(); const deliveredFinalTexts = new Set<string>();
let partialUpdateQueue: Promise<void> = Promise.resolve(); let partialUpdateQueue: Promise<void> = Promise.resolve();
let streamingStartPromise: Promise<void> | null = null; let streamingStartPromise: Promise<void> | null = null;
type StreamTextUpdateMode = "snapshot" | "delta"; type StreamTextUpdateMode = "snapshot" | "delta";
const formatReasoningPrefix = (thinking: string): string => {
if (!thinking) return "";
const withoutLabel = thinking.replace(/^Reasoning:\n/, "");
const plain = withoutLabel.replace(/^_(.*)_$/gm, "$1");
const lines = plain.split("\n").map((line) => `> ${line}`);
return `> 💭 **Thinking**\n${lines.join("\n")}`;
};
const buildCombinedStreamText = (thinking: string, answer: string): string => {
const parts: string[] = [];
if (thinking) parts.push(formatReasoningPrefix(thinking));
if (thinking && answer) parts.push("\n\n---\n\n");
if (answer) parts.push(answer);
return parts.join("");
};
const flushStreamingCardUpdate = (combined: string) => {
partialUpdateQueue = partialUpdateQueue.then(async () => {
if (streamingStartPromise) {
await streamingStartPromise;
}
if (streaming?.isActive()) {
await streaming.update(combined);
}
});
};
const queueStreamingUpdate = ( const queueStreamingUpdate = (
nextText: string, nextText: string,
options?: { options?: {
@ -167,14 +233,13 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
const mode = options?.mode ?? "snapshot"; const mode = options?.mode ?? "snapshot";
streamText = streamText =
mode === "delta" ? `${streamText}${nextText}` : mergeStreamingText(streamText, nextText); mode === "delta" ? `${streamText}${nextText}` : mergeStreamingText(streamText, nextText);
partialUpdateQueue = partialUpdateQueue.then(async () => { flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
if (streamingStartPromise) { };
await streamingStartPromise;
} const queueReasoningUpdate = (nextThinking: string) => {
if (streaming?.isActive()) { if (!nextThinking) return;
await streaming.update(streamText); reasoningText = nextThinking;
} flushStreamingCardUpdate(buildCombinedStreamText(reasoningText, streamText));
});
}; };
const startStreaming = () => { const startStreaming = () => {
@ -194,10 +259,14 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
params.runtime.log?.(`feishu[${account.accountId}] ${message}`), params.runtime.log?.(`feishu[${account.accountId}] ${message}`),
); );
try { try {
const cardHeader = resolveCardHeader(agentId, identity);
const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
await streaming.start(chatId, resolveReceiveIdType(chatId), { await streaming.start(chatId, resolveReceiveIdType(chatId), {
replyToMessageId, replyToMessageId,
replyInThread: effectiveReplyInThread, replyInThread: effectiveReplyInThread,
rootId, rootId,
header: cardHeader,
note: cardNote,
}); });
} catch (error) { } catch (error) {
params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`); params.runtime.error?.(`feishu: streaming start failed: ${String(error)}`);
@ -213,16 +282,18 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
} }
await partialUpdateQueue; await partialUpdateQueue;
if (streaming?.isActive()) { if (streaming?.isActive()) {
let text = streamText; let text = buildCombinedStreamText(reasoningText, streamText);
if (mentionTargets?.length) { if (mentionTargets?.length) {
text = buildMentionedCardContent(mentionTargets, text); text = buildMentionedCardContent(mentionTargets, text);
} }
await streaming.close(text); const finalNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
await streaming.close(text, { note: finalNote });
} }
streaming = null; streaming = null;
streamingStartPromise = null; streamingStartPromise = null;
streamText = ""; streamText = "";
lastPartial = ""; lastPartial = "";
reasoningText = "";
}; };
const sendChunkedTextReply = async (params: { const sendChunkedTextReply = async (params: {
@ -292,6 +363,7 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
if (shouldDeliverText) { if (shouldDeliverText) {
const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text)); const useCard = renderMode === "card" || (renderMode === "auto" && shouldUseCard(text));
let first = true;
if (info?.kind === "block") { if (info?.kind === "block") {
// Drop internal block chunks unless we can safely consume them as // Drop internal block chunks unless we can safely consume them as
@ -340,7 +412,29 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
} }
if (useCard) { if (useCard) {
await sendChunkedTextReply({ text, useCard: true, infoKind: info?.kind }); const cardHeader = resolveCardHeader(agentId, identity);
const cardNote = resolveCardNote(agentId, identity, prefixContext.prefixContext);
for (const chunk of core.channel.text.chunkTextWithMode(
text,
textChunkLimit,
chunkMode,
)) {
await sendStructuredCardFeishu({
cfg,
to: chatId,
text: chunk,
replyToMessageId: sendReplyToMessageId,
replyInThread: effectiveReplyInThread,
mentions: first ? mentionTargets : undefined,
accountId,
header: cardHeader,
note: cardNote,
});
first = false;
}
if (info?.kind === "final") {
deliveredFinalTexts.add(text);
}
} else { } else {
await sendChunkedTextReply({ text, useCard: false, infoKind: info?.kind }); await sendChunkedTextReply({ text, useCard: false, infoKind: info?.kind });
} }
@ -392,6 +486,16 @@ export function createFeishuReplyDispatcher(params: CreateFeishuReplyDispatcherP
}); });
} }
: undefined, : undefined,
onReasoningStream: streamingEnabled
? (payload: ReplyPayload) => {
if (!payload.text) {
return;
}
startStreaming();
queueReasoningUpdate(payload.text);
}
: undefined,
onReasoningEnd: streamingEnabled ? () => {} : undefined,
}, },
markDispatchIdle, markDispatchIdle,
}; };

View File

@ -1,9 +1,16 @@
import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu"; import type { ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { getMessageFeishu } from "./send.js"; import {
buildStructuredCard,
getMessageFeishu,
listFeishuThreadMessages,
resolveFeishuCardTemplate,
} from "./send.js";
const { mockClientGet, mockCreateFeishuClient, mockResolveFeishuAccount } = vi.hoisted(() => ({ const { mockClientGet, mockClientList, mockCreateFeishuClient, mockResolveFeishuAccount } =
vi.hoisted(() => ({
mockClientGet: vi.fn(), mockClientGet: vi.fn(),
mockClientList: vi.fn(),
mockCreateFeishuClient: vi.fn(), mockCreateFeishuClient: vi.fn(),
mockResolveFeishuAccount: vi.fn(), mockResolveFeishuAccount: vi.fn(),
})); }));
@ -27,6 +34,7 @@ describe("getMessageFeishu", () => {
im: { im: {
message: { message: {
get: mockClientGet, get: mockClientGet,
list: mockClientList,
}, },
}, },
}); });
@ -165,4 +173,98 @@ describe("getMessageFeishu", () => {
}), }),
); );
}); });
it("reuses the same content parsing for thread history messages", async () => {
mockClientList.mockResolvedValueOnce({
code: 0,
data: {
items: [
{
message_id: "om_root",
msg_type: "text",
body: {
content: JSON.stringify({ text: "root starter" }),
},
},
{
message_id: "om_card",
msg_type: "interactive",
body: {
content: JSON.stringify({
body: {
elements: [{ tag: "markdown", content: "hello from card 2.0" }],
},
}),
},
sender: {
id: "app_1",
sender_type: "app",
},
create_time: "1710000000000",
},
{
message_id: "om_file",
msg_type: "file",
body: {
content: JSON.stringify({ file_key: "file_v3_123" }),
},
sender: {
id: "ou_1",
sender_type: "user",
},
create_time: "1710000001000",
},
],
},
});
const result = await listFeishuThreadMessages({
cfg: {} as ClawdbotConfig,
threadId: "omt_1",
rootMessageId: "om_root",
});
expect(result).toEqual([
expect.objectContaining({
messageId: "om_file",
contentType: "file",
content: "[file message]",
}),
expect.objectContaining({
messageId: "om_card",
contentType: "interactive",
content: "hello from card 2.0",
}),
]);
});
});
describe("resolveFeishuCardTemplate", () => {
it("accepts supported Feishu templates", () => {
expect(resolveFeishuCardTemplate(" purple ")).toBe("purple");
});
it("drops unsupported free-form identity themes", () => {
expect(resolveFeishuCardTemplate("space lobster")).toBeUndefined();
});
});
describe("buildStructuredCard", () => {
it("falls back to blue when the header template is unsupported", () => {
const card = buildStructuredCard("hello", {
header: {
title: "Agent",
template: "space lobster",
},
});
expect(card).toEqual(
expect.objectContaining({
header: {
title: { tag: "plain_text", content: "Agent" },
template: "blue",
},
}),
);
});
}); });

View File

@ -10,6 +10,21 @@ import { resolveFeishuSendTarget } from "./send-target.js";
import type { FeishuChatType, FeishuMessageInfo, FeishuSendResult } from "./types.js"; import type { FeishuChatType, FeishuMessageInfo, FeishuSendResult } from "./types.js";
const WITHDRAWN_REPLY_ERROR_CODES = new Set([230011, 231003]); const WITHDRAWN_REPLY_ERROR_CODES = new Set([230011, 231003]);
const FEISHU_CARD_TEMPLATES = new Set([
"blue",
"green",
"red",
"orange",
"purple",
"indigo",
"wathet",
"turquoise",
"yellow",
"grey",
"carmine",
"violet",
"lime",
]);
function shouldFallbackFromReplyTarget(response: { code?: number; msg?: string }): boolean { function shouldFallbackFromReplyTarget(response: { code?: number; msg?: string }): boolean {
if (response.code !== undefined && WITHDRAWN_REPLY_ERROR_CODES.has(response.code)) { if (response.code !== undefined && WITHDRAWN_REPLY_ERROR_CODES.has(response.code)) {
@ -65,6 +80,7 @@ type FeishuMessageGetItem = {
message_id?: string; message_id?: string;
chat_id?: string; chat_id?: string;
chat_type?: FeishuChatType; chat_type?: FeishuChatType;
thread_id?: string;
msg_type?: string; msg_type?: string;
body?: { content?: string }; body?: { content?: string };
sender?: FeishuMessageSender; sender?: FeishuMessageSender;
@ -151,13 +167,19 @@ function parseInteractiveCardContent(parsed: unknown): string {
return "[Interactive Card]"; return "[Interactive Card]";
} }
const candidate = parsed as { elements?: unknown }; // Support both schema 1.0 (top-level `elements`) and 2.0 (`body.elements`).
if (!Array.isArray(candidate.elements)) { const candidate = parsed as { elements?: unknown; body?: { elements?: unknown } };
const elements = Array.isArray(candidate.elements)
? candidate.elements
: Array.isArray(candidate.body?.elements)
? candidate.body!.elements
: null;
if (!elements) {
return "[Interactive Card]"; return "[Interactive Card]";
} }
const texts: string[] = []; const texts: string[] = [];
for (const element of candidate.elements) { for (const element of elements) {
if (!element || typeof element !== "object") { if (!element || typeof element !== "object") {
continue; continue;
} }
@ -177,7 +199,7 @@ function parseInteractiveCardContent(parsed: unknown): string {
return texts.join("\n").trim() || "[Interactive Card]"; return texts.join("\n").trim() || "[Interactive Card]";
} }
function parseQuotedMessageContent(rawContent: string, msgType: string): string { function parseFeishuMessageContent(rawContent: string, msgType: string): string {
if (!rawContent) { if (!rawContent) {
return ""; return "";
} }
@ -218,6 +240,30 @@ function parseQuotedMessageContent(rawContent: string, msgType: string): string
return `[${msgType || "unknown"} message]`; return `[${msgType || "unknown"} message]`;
} }
function parseFeishuMessageItem(
item: FeishuMessageGetItem,
fallbackMessageId?: string,
): FeishuMessageInfo {
const msgType = item.msg_type ?? "text";
const rawContent = item.body?.content ?? "";
return {
messageId: item.message_id ?? fallbackMessageId ?? "",
chatId: item.chat_id ?? "",
chatType:
item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p"
? item.chat_type
: undefined,
senderId: item.sender?.id,
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
senderType: item.sender?.sender_type,
content: parseFeishuMessageContent(rawContent, msgType),
contentType: msgType,
createTime: item.create_time ? parseInt(String(item.create_time), 10) : undefined,
threadId: item.thread_id || undefined,
};
}
/** /**
* Get a message by its ID. * Get a message by its ID.
* Useful for fetching quoted/replied message content. * Useful for fetching quoted/replied message content.
@ -255,29 +301,98 @@ export async function getMessageFeishu(params: {
return null; return null;
} }
const msgType = item.msg_type ?? "text"; return parseFeishuMessageItem(item, messageId);
const rawContent = item.body?.content ?? "";
const content = parseQuotedMessageContent(rawContent, msgType);
return {
messageId: item.message_id ?? messageId,
chatId: item.chat_id ?? "",
chatType:
item.chat_type === "group" || item.chat_type === "private" || item.chat_type === "p2p"
? item.chat_type
: undefined,
senderId: item.sender?.id,
senderOpenId: item.sender?.id_type === "open_id" ? item.sender?.id : undefined,
senderType: item.sender?.sender_type,
content,
contentType: msgType,
createTime: item.create_time ? parseInt(String(item.create_time), 10) : undefined,
};
} catch { } catch {
return null; return null;
} }
} }
export type FeishuThreadMessageInfo = {
messageId: string;
senderId?: string;
senderType?: string;
content: string;
contentType: string;
createTime?: number;
};
/**
* List messages in a Feishu thread (topic).
* Uses container_id_type=thread to directly query thread messages,
* which includes both the root message and all replies (including bot replies).
*/
export async function listFeishuThreadMessages(params: {
cfg: ClawdbotConfig;
threadId: string;
currentMessageId?: string;
/** Exclude the root message (already provided separately as ThreadStarterBody). */
rootMessageId?: string;
limit?: number;
accountId?: string;
}): Promise<FeishuThreadMessageInfo[]> {
const { cfg, threadId, currentMessageId, rootMessageId, limit = 20, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
if (!account.configured) {
throw new Error(`Feishu account "${account.accountId}" not configured`);
}
const client = createFeishuClient(account);
const response = (await client.im.message.list({
params: {
container_id_type: "thread",
container_id: threadId,
// Fetch newest messages first so long threads keep the most recent turns.
// Results are reversed below to restore chronological order.
sort_type: "ByCreateTimeDesc",
page_size: Math.min(limit + 1, 50),
},
})) as {
code?: number;
msg?: string;
data?: {
items?: Array<
{
message_id?: string;
root_id?: string;
parent_id?: string;
} & FeishuMessageGetItem
>;
};
};
if (response.code !== 0) {
throw new Error(
`Feishu thread list failed: code=${response.code} msg=${response.msg ?? "unknown"}`,
);
}
const items = response.data?.items ?? [];
const results: FeishuThreadMessageInfo[] = [];
for (const item of items) {
if (currentMessageId && item.message_id === currentMessageId) continue;
if (rootMessageId && item.message_id === rootMessageId) continue;
const parsed = parseFeishuMessageItem(item);
results.push({
messageId: parsed.messageId,
senderId: parsed.senderId,
senderType: parsed.senderType,
content: parsed.content,
contentType: parsed.contentType,
createTime: parsed.createTime,
});
if (results.length >= limit) break;
}
// Restore chronological order (oldest first) since we fetched newest-first.
results.reverse();
return results;
}
export type SendFeishuMessageParams = { export type SendFeishuMessageParams = {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
to: string; to: string;
@ -418,6 +533,77 @@ export function buildMarkdownCard(text: string): Record<string, unknown> {
}; };
} }
/** Header configuration for structured Feishu cards. */
export type CardHeaderConfig = {
/** Header title text, e.g. "💻 Coder" */
title: string;
/** Feishu header color template (blue, green, red, orange, purple, grey, etc.). Defaults to "blue". */
template?: string;
};
export function resolveFeishuCardTemplate(template?: string): string | undefined {
const normalized = template?.trim().toLowerCase();
if (!normalized || !FEISHU_CARD_TEMPLATES.has(normalized)) {
return undefined;
}
return normalized;
}
/**
* Build a Feishu interactive card with optional header and note footer.
* When header/note are omitted, behaves identically to buildMarkdownCard.
*/
export function buildStructuredCard(
text: string,
options?: {
header?: CardHeaderConfig;
note?: string;
},
): Record<string, unknown> {
const elements: Record<string, unknown>[] = [{ tag: "markdown", content: text }];
if (options?.note) {
elements.push({ tag: "hr" });
elements.push({ tag: "markdown", content: `<font color='grey'>${options.note}</font>` });
}
const card: Record<string, unknown> = {
schema: "2.0",
config: { wide_screen_mode: true },
body: { elements },
};
if (options?.header) {
card.header = {
title: { tag: "plain_text", content: options.header.title },
template: resolveFeishuCardTemplate(options.header.template) ?? "blue",
};
}
return card;
}
/**
* Send a message as a structured card with optional header and note.
*/
export async function sendStructuredCardFeishu(params: {
cfg: ClawdbotConfig;
to: string;
text: string;
replyToMessageId?: string;
/** When true, reply creates a Feishu topic thread instead of an inline reply */
replyInThread?: boolean;
mentions?: MentionTarget[];
accountId?: string;
header?: CardHeaderConfig;
note?: string;
}): Promise<FeishuSendResult> {
const { cfg, to, text, replyToMessageId, replyInThread, mentions, accountId, header, note } =
params;
let cardText = text;
if (mentions && mentions.length > 0) {
cardText = buildMentionedCardContent(mentions, text);
}
const card = buildStructuredCard(cardText, { header, note });
return sendCardFeishu({ cfg, to, card, replyToMessageId, replyInThread, accountId });
}
/** /**
* Send a message as a markdown card (interactive message). * Send a message as a markdown card (interactive message).
* This renders markdown properly in Feishu (code blocks, tables, bold/italic, etc.) * This renders markdown properly in Feishu (code blocks, tables, bold/italic, etc.)

View File

@ -4,10 +4,25 @@
import type { Client } from "@larksuiteoapi/node-sdk"; import type { Client } from "@larksuiteoapi/node-sdk";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/feishu"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/feishu";
import { resolveFeishuCardTemplate, type CardHeaderConfig } from "./send.js";
import type { FeishuDomain } from "./types.js"; import type { FeishuDomain } from "./types.js";
type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain }; type Credentials = { appId: string; appSecret: string; domain?: FeishuDomain };
type CardState = { cardId: string; messageId: string; sequence: number; currentText: string }; type CardState = {
cardId: string;
messageId: string;
sequence: number;
currentText: string;
hasNote: boolean;
};
/** Options for customising the initial streaming card appearance. */
export type StreamingCardOptions = {
/** Optional header with title and color template. */
header?: CardHeaderConfig;
/** Optional grey note footer text. */
note?: string;
};
/** Optional header for streaming cards (title bar with color template) */ /** Optional header for streaming cards (title bar with color template) */
export type StreamingCardHeader = { export type StreamingCardHeader = {
@ -152,6 +167,7 @@ export class FeishuStreamingSession {
private log?: (msg: string) => void; private log?: (msg: string) => void;
private lastUpdateTime = 0; private lastUpdateTime = 0;
private pendingText: string | null = null; private pendingText: string | null = null;
private flushTimer: ReturnType<typeof setTimeout> | null = null;
private updateThrottleMs = 100; // Throttle updates to max 10/sec private updateThrottleMs = 100; // Throttle updates to max 10/sec
constructor(client: Client, creds: Credentials, log?: (msg: string) => void) { constructor(client: Client, creds: Credentials, log?: (msg: string) => void) {
@ -163,13 +179,24 @@ export class FeishuStreamingSession {
async start( async start(
receiveId: string, receiveId: string,
receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id", receiveIdType: "open_id" | "user_id" | "union_id" | "email" | "chat_id" = "chat_id",
options?: StreamingStartOptions, options?: StreamingCardOptions & StreamingStartOptions,
): Promise<void> { ): Promise<void> {
if (this.state) { if (this.state) {
return; return;
} }
const apiBase = resolveApiBase(this.creds.domain); const apiBase = resolveApiBase(this.creds.domain);
const elements: Record<string, unknown>[] = [
{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" },
];
if (options?.note) {
elements.push({ tag: "hr" });
elements.push({
tag: "markdown",
content: `<font color='grey'>${options.note}</font>`,
element_id: "note",
});
}
const cardJson: Record<string, unknown> = { const cardJson: Record<string, unknown> = {
schema: "2.0", schema: "2.0",
config: { config: {
@ -177,14 +204,12 @@ export class FeishuStreamingSession {
summary: { content: "[Generating...]" }, summary: { content: "[Generating...]" },
streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 1 } }, streaming_config: { print_frequency_ms: { default: 50 }, print_step: { default: 1 } },
}, },
body: { body: { elements },
elements: [{ tag: "markdown", content: "⏳ Thinking...", element_id: "content" }],
},
}; };
if (options?.header) { if (options?.header) {
cardJson.header = { cardJson.header = {
title: { tag: "plain_text", content: options.header.title }, title: { tag: "plain_text", content: options.header.title },
template: options.header.template ?? "blue", template: resolveFeishuCardTemplate(options.header.template) ?? "blue",
}; };
} }
@ -257,7 +282,13 @@ export class FeishuStreamingSession {
throw new Error(`Send card failed: ${sendRes.msg}`); throw new Error(`Send card failed: ${sendRes.msg}`);
} }
this.state = { cardId, messageId: sendRes.data.message_id, sequence: 1, currentText: "" }; this.state = {
cardId,
messageId: sendRes.data.message_id,
sequence: 1,
currentText: "",
hasNote: !!options?.note,
};
this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`); this.log?.(`Started streaming: cardId=${cardId}, messageId=${sendRes.data.message_id}`);
} }
@ -307,6 +338,10 @@ export class FeishuStreamingSession {
} }
this.pendingText = null; this.pendingText = null;
this.lastUpdateTime = now; this.lastUpdateTime = now;
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
this.queue = this.queue.then(async () => { this.queue = this.queue.then(async () => {
if (!this.state || this.closed) { if (!this.state || this.closed) {
@ -322,11 +357,44 @@ export class FeishuStreamingSession {
await this.queue; await this.queue;
} }
async close(finalText?: string): Promise<void> { private async updateNoteContent(note: string): Promise<void> {
if (!this.state || !this.state.hasNote) {
return;
}
const apiBase = resolveApiBase(this.creds.domain);
this.state.sequence += 1;
await fetchWithSsrFGuard({
url: `${apiBase}/cardkit/v1/cards/${this.state.cardId}/elements/note/content`,
init: {
method: "PUT",
headers: {
Authorization: `Bearer ${await getToken(this.creds)}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
content: `<font color='grey'>${note}</font>`,
sequence: this.state.sequence,
uuid: `n_${this.state.cardId}_${this.state.sequence}`,
}),
},
policy: { allowedHostnames: resolveAllowedHostnames(this.creds.domain) },
auditContext: "feishu.streaming-card.note-update",
})
.then(async ({ release }) => {
await release();
})
.catch((e) => this.log?.(`Note update failed: ${String(e)}`));
}
async close(finalText?: string, options?: { note?: string }): Promise<void> {
if (!this.state || this.closed) { if (!this.state || this.closed) {
return; return;
} }
this.closed = true; this.closed = true;
if (this.flushTimer) {
clearTimeout(this.flushTimer);
this.flushTimer = null;
}
await this.queue; await this.queue;
const pendingMerged = mergeStreamingText(this.state.currentText, this.pendingText ?? undefined); const pendingMerged = mergeStreamingText(this.state.currentText, this.pendingText ?? undefined);
@ -339,6 +407,11 @@ export class FeishuStreamingSession {
this.state.currentText = text; this.state.currentText = text;
} }
// Update note with final model/provider info
if (options?.note) {
await this.updateNoteContent(options.note);
}
// Close streaming mode // Close streaming mode
this.state.sequence += 1; this.state.sequence += 1;
await fetchWithSsrFGuard({ await fetchWithSsrFGuard({
@ -364,8 +437,11 @@ export class FeishuStreamingSession {
await release(); await release();
}) })
.catch((e) => this.log?.(`Close failed: ${String(e)}`)); .catch((e) => this.log?.(`Close failed: ${String(e)}`));
const finalState = this.state;
this.state = null;
this.pendingText = null;
this.log?.(`Closed streaming: cardId=${this.state.cardId}`); this.log?.(`Closed streaming: cardId=${finalState.cardId}`);
} }
isActive(): boolean { isActive(): boolean {

View File

@ -72,6 +72,8 @@ export type FeishuMessageInfo = {
content: string; content: string;
contentType: string; contentType: string;
createTime?: number; createTime?: number;
/** Feishu thread ID (omt_xxx) — present when the message belongs to a topic thread. */
threadId?: string;
}; };
export type FeishuProbeResult = BaseProbeResult<string> & { export type FeishuProbeResult = BaseProbeResult<string> & {

View File

@ -403,3 +403,30 @@ describe("telegramPlugin duplicate token guard", () => {
); );
}); });
}); });
describe("telegramPlugin outbound sendPayload forceDocument", () => {
it("forwards forceDocument to the underlying send call when channelData is present", async () => {
const sendMessageTelegram = installSendMessageRuntime(
vi.fn(async () => ({ messageId: "tg-fd" })),
);
await telegramPlugin.outbound!.sendPayload!({
cfg: createCfg(),
to: "12345",
text: "",
payload: {
text: "here is an image",
mediaUrls: ["https://example.com/photo.png"],
channelData: { telegram: {} },
},
accountId: "ops",
forceDocument: true,
});
expect(sendMessageTelegram).toHaveBeenCalledWith(
"12345",
expect.any(String),
expect.objectContaining({ forceDocument: true }),
);
});
});

View File

@ -96,6 +96,7 @@ function buildTelegramSendOptions(params: {
replyToId?: string | null; replyToId?: string | null;
threadId?: string | number | null; threadId?: string | number | null;
silent?: boolean | null; silent?: boolean | null;
forceDocument?: boolean | null;
}): TelegramSendOptions { }): TelegramSendOptions {
return { return {
verbose: false, verbose: false,
@ -106,6 +107,7 @@ function buildTelegramSendOptions(params: {
replyToMessageId: parseTelegramReplyToMessageId(params.replyToId), replyToMessageId: parseTelegramReplyToMessageId(params.replyToId),
accountId: params.accountId ?? undefined, accountId: params.accountId ?? undefined,
silent: params.silent ?? undefined, silent: params.silent ?? undefined,
forceDocument: params.forceDocument ?? undefined,
}; };
} }
@ -386,6 +388,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
replyToId, replyToId,
threadId, threadId,
silent, silent,
forceDocument,
}) => { }) => {
const send = const send =
resolveOutboundSendDep<TelegramSendFn>(deps, "telegram") ?? resolveOutboundSendDep<TelegramSendFn>(deps, "telegram") ??
@ -401,6 +404,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
replyToId, replyToId,
threadId, threadId,
silent, silent,
forceDocument,
}), }),
}); });
return { channel: "telegram", ...result }; return { channel: "telegram", ...result };

View File

@ -512,6 +512,146 @@ function sliceLinkSpans(
}); });
} }
function sliceMarkdownIR(ir: MarkdownIR, start: number, end: number): MarkdownIR {
return {
text: ir.text.slice(start, end),
styles: sliceStyleSpans(ir.styles, start, end),
links: sliceLinkSpans(ir.links, start, end),
};
}
function mergeAdjacentStyleSpans(styles: MarkdownIR["styles"]): MarkdownIR["styles"] {
const merged: MarkdownIR["styles"] = [];
for (const span of styles) {
const last = merged.at(-1);
if (last && last.style === span.style && span.start <= last.end) {
last.end = Math.max(last.end, span.end);
continue;
}
merged.push({ ...span });
}
return merged;
}
function mergeAdjacentLinkSpans(links: MarkdownIR["links"]): MarkdownIR["links"] {
const merged: MarkdownIR["links"] = [];
for (const link of links) {
const last = merged.at(-1);
if (last && last.href === link.href && link.start <= last.end) {
last.end = Math.max(last.end, link.end);
continue;
}
merged.push({ ...link });
}
return merged;
}
function mergeMarkdownIRChunks(left: MarkdownIR, right: MarkdownIR): MarkdownIR {
const offset = left.text.length;
return {
text: left.text + right.text,
styles: mergeAdjacentStyleSpans([
...left.styles,
...right.styles.map((span) => ({
...span,
start: span.start + offset,
end: span.end + offset,
})),
]),
links: mergeAdjacentLinkSpans([
...left.links,
...right.links.map((link) => ({
...link,
start: link.start + offset,
end: link.end + offset,
})),
]),
};
}
function renderTelegramChunkHtml(ir: MarkdownIR): string {
return wrapFileReferencesInHtml(renderTelegramHtml(ir));
}
function findMarkdownIRPreservedSplitIndex(text: string, start: number, limit: number): number {
const maxEnd = Math.min(text.length, start + limit);
if (maxEnd >= text.length) {
return text.length;
}
let lastOutsideParenNewlineBreak = -1;
let lastOutsideParenWhitespaceBreak = -1;
let lastOutsideParenWhitespaceRunStart = -1;
let lastAnyNewlineBreak = -1;
let lastAnyWhitespaceBreak = -1;
let lastAnyWhitespaceRunStart = -1;
let parenDepth = 0;
let sawNonWhitespace = false;
for (let index = start; index < maxEnd; index += 1) {
const char = text[index];
if (char === "(") {
sawNonWhitespace = true;
parenDepth += 1;
continue;
}
if (char === ")" && parenDepth > 0) {
sawNonWhitespace = true;
parenDepth -= 1;
continue;
}
if (!/\s/.test(char)) {
sawNonWhitespace = true;
continue;
}
if (!sawNonWhitespace) {
continue;
}
if (char === "\n") {
lastAnyNewlineBreak = index + 1;
if (parenDepth === 0) {
lastOutsideParenNewlineBreak = index + 1;
}
continue;
}
const whitespaceRunStart =
index === start || !/\s/.test(text[index - 1] ?? "") ? index : lastAnyWhitespaceRunStart;
lastAnyWhitespaceBreak = index + 1;
lastAnyWhitespaceRunStart = whitespaceRunStart;
if (parenDepth === 0) {
lastOutsideParenWhitespaceBreak = index + 1;
lastOutsideParenWhitespaceRunStart = whitespaceRunStart;
}
}
const resolveWhitespaceBreak = (breakIndex: number, runStart: number): number => {
if (breakIndex <= start) {
return breakIndex;
}
if (runStart <= start) {
return breakIndex;
}
return /\s/.test(text[breakIndex] ?? "") ? runStart : breakIndex;
};
if (lastOutsideParenNewlineBreak > start) {
return lastOutsideParenNewlineBreak;
}
if (lastOutsideParenWhitespaceBreak > start) {
return resolveWhitespaceBreak(
lastOutsideParenWhitespaceBreak,
lastOutsideParenWhitespaceRunStart,
);
}
if (lastAnyNewlineBreak > start) {
return lastAnyNewlineBreak;
}
if (lastAnyWhitespaceBreak > start) {
return resolveWhitespaceBreak(lastAnyWhitespaceBreak, lastAnyWhitespaceRunStart);
}
return maxEnd;
}
function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): MarkdownIR[] { function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): MarkdownIR[] {
if (!ir.text) { if (!ir.text) {
return []; return [];
@ -523,7 +663,7 @@ function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): Markd
const chunks: MarkdownIR[] = []; const chunks: MarkdownIR[] = [];
let cursor = 0; let cursor = 0;
while (cursor < ir.text.length) { while (cursor < ir.text.length) {
const end = Math.min(ir.text.length, cursor + normalizedLimit); const end = findMarkdownIRPreservedSplitIndex(ir.text, cursor, normalizedLimit);
chunks.push({ chunks.push({
text: ir.text.slice(cursor, end), text: ir.text.slice(cursor, end),
styles: sliceStyleSpans(ir.styles, cursor, end), styles: sliceStyleSpans(ir.styles, cursor, end),
@ -534,32 +674,98 @@ function splitMarkdownIRPreserveWhitespace(ir: MarkdownIR, limit: number): Markd
return chunks; return chunks;
} }
function coalesceWhitespaceOnlyMarkdownIRChunks(chunks: MarkdownIR[], limit: number): MarkdownIR[] {
const coalesced: MarkdownIR[] = [];
let index = 0;
while (index < chunks.length) {
const chunk = chunks[index];
if (!chunk) {
index += 1;
continue;
}
if (chunk.text.trim().length > 0) {
coalesced.push(chunk);
index += 1;
continue;
}
const prev = coalesced.at(-1);
const next = chunks[index + 1];
const chunkLength = chunk.text.length;
const canMergePrev = (candidate: MarkdownIR) =>
renderTelegramChunkHtml(candidate).length <= limit;
const canMergeNext = (candidate: MarkdownIR) =>
renderTelegramChunkHtml(candidate).length <= limit;
if (prev) {
const mergedPrev = mergeMarkdownIRChunks(prev, chunk);
if (canMergePrev(mergedPrev)) {
coalesced[coalesced.length - 1] = mergedPrev;
index += 1;
continue;
}
}
if (next) {
const mergedNext = mergeMarkdownIRChunks(chunk, next);
if (canMergeNext(mergedNext)) {
chunks[index + 1] = mergedNext;
index += 1;
continue;
}
}
if (prev && next) {
for (let prefixLength = chunkLength - 1; prefixLength >= 1; prefixLength -= 1) {
const prefix = sliceMarkdownIR(chunk, 0, prefixLength);
const suffix = sliceMarkdownIR(chunk, prefixLength, chunkLength);
const mergedPrev = mergeMarkdownIRChunks(prev, prefix);
const mergedNext = mergeMarkdownIRChunks(suffix, next);
if (canMergePrev(mergedPrev) && canMergeNext(mergedNext)) {
coalesced[coalesced.length - 1] = mergedPrev;
chunks[index + 1] = mergedNext;
break;
}
}
}
index += 1;
}
return coalesced;
}
function renderTelegramChunksWithinHtmlLimit( function renderTelegramChunksWithinHtmlLimit(
ir: MarkdownIR, ir: MarkdownIR,
limit: number, limit: number,
): TelegramFormattedChunk[] { ): TelegramFormattedChunk[] {
const normalizedLimit = Math.max(1, Math.floor(limit)); const normalizedLimit = Math.max(1, Math.floor(limit));
const pending = chunkMarkdownIR(ir, normalizedLimit); const pending = chunkMarkdownIR(ir, normalizedLimit);
const rendered: TelegramFormattedChunk[] = []; const finalized: MarkdownIR[] = [];
while (pending.length > 0) { while (pending.length > 0) {
const chunk = pending.shift(); const chunk = pending.shift();
if (!chunk) { if (!chunk) {
continue; continue;
} }
const html = wrapFileReferencesInHtml(renderTelegramHtml(chunk)); const html = renderTelegramChunkHtml(chunk);
if (html.length <= normalizedLimit || chunk.text.length <= 1) { if (html.length <= normalizedLimit || chunk.text.length <= 1) {
rendered.push({ html, text: chunk.text }); finalized.push(chunk);
continue; continue;
} }
const split = splitTelegramChunkByHtmlLimit(chunk, normalizedLimit, html.length); const split = splitTelegramChunkByHtmlLimit(chunk, normalizedLimit, html.length);
if (split.length <= 1) { if (split.length <= 1) {
// Worst-case safety: avoid retry loops, deliver the chunk as-is. // Worst-case safety: avoid retry loops, deliver the chunk as-is.
rendered.push({ html, text: chunk.text }); finalized.push(chunk);
continue; continue;
} }
pending.unshift(...split); pending.unshift(...split);
} }
return rendered; return coalesceWhitespaceOnlyMarkdownIRChunks(finalized, normalizedLimit).map((chunk) => ({
html: renderTelegramChunkHtml(chunk),
text: chunk.text,
}));
} }
export function markdownToTelegramChunks( export function markdownToTelegramChunks(

View File

@ -174,6 +174,35 @@ describe("markdownToTelegramChunks - file reference wrapping", () => {
expect(chunks.map((chunk) => chunk.text).join("")).toBe(input); expect(chunks.map((chunk) => chunk.text).join("")).toBe(input);
expect(chunks.every((chunk) => chunk.html.length <= 5)).toBe(true); expect(chunks.every((chunk) => chunk.html.length <= 5)).toBe(true);
}); });
it("prefers word boundaries when html-limit retry splits formatted prose", () => {
const input = "**Which of these**";
const chunks = markdownToTelegramChunks(input, 16);
expect(chunks.map((chunk) => chunk.text)).toEqual(["Which of ", "these"]);
expect(chunks.every((chunk) => chunk.html.length <= 16)).toBe(true);
});
it("falls back to in-paren word boundaries when the parenthesis is unbalanced", () => {
const input = "**foo (bar baz qux quux**";
const chunks = markdownToTelegramChunks(input, 20);
expect(chunks.map((chunk) => chunk.text)).toEqual(["foo", "(bar baz qux ", "quux"]);
expect(chunks.every((chunk) => chunk.html.length <= 20)).toBe(true);
});
it("does not emit whitespace-only chunks during html-limit retry splitting", () => {
const input = "**ab <<**";
const chunks = markdownToTelegramChunks(input, 11);
expect(chunks.map((chunk) => chunk.text).join("")).toBe("ab <<");
expect(chunks.every((chunk) => chunk.text.trim().length > 0)).toBe(true);
expect(chunks.every((chunk) => chunk.html.length <= 11)).toBe(true);
});
it("preserves paragraph separators when retry chunking produces whitespace-only spans", () => {
const input = "ab\n\n<<";
const chunks = markdownToTelegramChunks(input, 6);
expect(chunks.map((chunk) => chunk.text).join("")).toBe(input);
expect(chunks.every((chunk) => chunk.html.length <= 6)).toBe(true);
});
}); });
describe("edge cases", () => { describe("edge cases", () => {

View File

@ -141,6 +141,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
deps, deps,
replyToId, replyToId,
threadId, threadId,
forceDocument,
}) => { }) => {
const { send, baseOpts } = resolveTelegramSendContext({ const { send, baseOpts } = resolveTelegramSendContext({
cfg, cfg,
@ -156,6 +157,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
baseOpts: { baseOpts: {
...baseOpts, ...baseOpts,
mediaLocalRoots, mediaLocalRoots,
forceDocument: forceDocument ?? false,
}, },
}); });
return { channel: "telegram", ...result }; return { channel: "telegram", ...result };

View File

@ -301,7 +301,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
`[tlon] Using autoDiscoverChannels from settings store: ${effectiveAutoDiscoverChannels}`, `[tlon] Using autoDiscoverChannels from settings store: ${effectiveAutoDiscoverChannels}`,
); );
} }
if (currentSettings.dmAllowlist?.length) { if (currentSettings.dmAllowlist !== undefined) {
effectiveDmAllowlist = currentSettings.dmAllowlist; effectiveDmAllowlist = currentSettings.dmAllowlist;
runtime.log?.( runtime.log?.(
`[tlon] Using dmAllowlist from settings store: ${effectiveDmAllowlist.join(", ")}`, `[tlon] Using dmAllowlist from settings store: ${effectiveDmAllowlist.join(", ")}`,
@ -322,7 +322,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
`[tlon] Using autoAcceptGroupInvites from settings store: ${effectiveAutoAcceptGroupInvites}`, `[tlon] Using autoAcceptGroupInvites from settings store: ${effectiveAutoAcceptGroupInvites}`,
); );
} }
if (currentSettings.groupInviteAllowlist?.length) { if (currentSettings.groupInviteAllowlist !== undefined) {
effectiveGroupInviteAllowlist = currentSettings.groupInviteAllowlist; effectiveGroupInviteAllowlist = currentSettings.groupInviteAllowlist;
runtime.log?.( runtime.log?.(
`[tlon] Using groupInviteAllowlist from settings store: ${effectiveGroupInviteAllowlist.join(", ")}`, `[tlon] Using groupInviteAllowlist from settings store: ${effectiveGroupInviteAllowlist.join(", ")}`,
@ -1176,17 +1176,14 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
return; return;
} }
// Resolve any cited/quoted messages first
const citedContent = await resolveAllCites(content.content);
const rawText = extractMessageText(content.content); const rawText = extractMessageText(content.content);
const messageText = citedContent + rawText; if (!rawText.trim()) {
if (!messageText.trim()) {
return; return;
} }
cacheMessage(nest, { cacheMessage(nest, {
author: senderShip, author: senderShip,
content: messageText, content: rawText,
timestamp: content.sent || Date.now(), timestamp: content.sent || Date.now(),
id: messageId, id: messageId,
}); });
@ -1200,7 +1197,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
// Check if we should respond: // Check if we should respond:
// 1. Direct mention always triggers response // 1. Direct mention always triggers response
// 2. Thread replies where we've participated - respond if relevant (let agent decide) // 2. Thread replies where we've participated - respond if relevant (let agent decide)
const mentioned = isBotMentioned(messageText, botShipName, botNickname ?? undefined); const mentioned = isBotMentioned(rawText, botShipName, botNickname ?? undefined);
const inParticipatedThread = const inParticipatedThread =
isThreadReply && parentId && participatedThreads.has(String(parentId)); isThreadReply && parentId && participatedThreads.has(String(parentId));
@ -1227,10 +1224,10 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
type: "channel", type: "channel",
requestingShip: senderShip, requestingShip: senderShip,
channelNest: nest, channelNest: nest,
messagePreview: messageText.substring(0, 100), messagePreview: rawText.substring(0, 100),
originalMessage: { originalMessage: {
messageId: messageId ?? "", messageId: messageId ?? "",
messageText, messageText: rawText,
messageContent: content.content, messageContent: content.content,
timestamp: content.sent || Date.now(), timestamp: content.sent || Date.now(),
parentId: parentId ?? undefined, parentId: parentId ?? undefined,
@ -1248,6 +1245,10 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
} }
} }
// Resolve quoted content only after the sender passed channel authorization.
const citedContent = await resolveAllCites(content.content);
const messageText = citedContent + rawText;
const parsed = parseChannelNest(nest); const parsed = parseChannelNest(nest);
await processMessage({ await processMessage({
messageId: messageId ?? "", messageId: messageId ?? "",
@ -1365,15 +1366,15 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
); );
} }
// Resolve any cited/quoted messages first
const citedContent = await resolveAllCites(essay.content);
const rawText = extractMessageText(essay.content); const rawText = extractMessageText(essay.content);
const messageText = citedContent + rawText; if (!rawText.trim()) {
if (!messageText.trim()) {
return; return;
} }
const citedContent = await resolveAllCites(essay.content);
const resolvedMessageText = citedContent + rawText;
// Check if this is the owner sending an approval response // Check if this is the owner sending an approval response
const messageText = rawText;
if (isOwner(senderShip) && isApprovalResponse(messageText)) { if (isOwner(senderShip) && isApprovalResponse(messageText)) {
const handled = await handleApprovalResponse(messageText); const handled = await handleApprovalResponse(messageText);
if (handled) { if (handled) {
@ -1397,7 +1398,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
await processMessage({ await processMessage({
messageId: messageId ?? "", messageId: messageId ?? "",
senderShip, senderShip,
messageText, messageText: resolvedMessageText,
messageContent: essay.content, messageContent: essay.content,
isGroup: false, isGroup: false,
timestamp: essay.sent || Date.now(), timestamp: essay.sent || Date.now(),
@ -1430,7 +1431,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
await processMessage({ await processMessage({
messageId: messageId ?? "", messageId: messageId ?? "",
senderShip, senderShip,
messageText, messageText: resolvedMessageText,
messageContent: essay.content, // Pass raw content for media extraction messageContent: essay.content, // Pass raw content for media extraction
isGroup: false, isGroup: false,
timestamp: essay.sent || Date.now(), timestamp: essay.sent || Date.now(),
@ -1524,8 +1525,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
// Update DM allowlist // Update DM allowlist
if (newSettings.dmAllowlist !== undefined) { if (newSettings.dmAllowlist !== undefined) {
effectiveDmAllowlist = effectiveDmAllowlist = newSettings.dmAllowlist;
newSettings.dmAllowlist.length > 0 ? newSettings.dmAllowlist : account.dmAllowlist;
runtime.log?.(`[tlon] Settings: dmAllowlist updated to ${effectiveDmAllowlist.join(", ")}`); runtime.log?.(`[tlon] Settings: dmAllowlist updated to ${effectiveDmAllowlist.join(", ")}`);
} }
@ -1551,10 +1551,7 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
// Update group invite allowlist // Update group invite allowlist
if (newSettings.groupInviteAllowlist !== undefined) { if (newSettings.groupInviteAllowlist !== undefined) {
effectiveGroupInviteAllowlist = effectiveGroupInviteAllowlist = newSettings.groupInviteAllowlist;
newSettings.groupInviteAllowlist.length > 0
? newSettings.groupInviteAllowlist
: account.groupInviteAllowlist;
runtime.log?.( runtime.log?.(
`[tlon] Settings: groupInviteAllowlist updated to ${effectiveGroupInviteAllowlist.join(", ")}`, `[tlon] Settings: groupInviteAllowlist updated to ${effectiveGroupInviteAllowlist.join(", ")}`,
); );

View File

@ -413,8 +413,14 @@ export async function monitorWebInbox(options: {
// If this is history/offline catch-up, mark read above but skip auto-reply. // If this is history/offline catch-up, mark read above but skip auto-reply.
if (upsert.type === "append") { if (upsert.type === "append") {
const APPEND_RECENT_GRACE_MS = 60_000;
const msgTsRaw = msg.messageTimestamp;
const msgTsNum = msgTsRaw != null ? Number(msgTsRaw) : NaN;
const msgTsMs = Number.isFinite(msgTsNum) ? msgTsNum * 1000 : 0;
if (msgTsMs < connectedAtMs - APPEND_RECENT_GRACE_MS) {
continue; continue;
} }
}
const enriched = await enrichInboundMessage(msg); const enriched = await enrichInboundMessage(msg);
if (!enriched) { if (!enriched) {

View File

@ -1,6 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { startWebLoginWithQr, waitForWebLogin } from "./login-qr.js"; import { startWebLoginWithQr, waitForWebLogin } from "./login-qr.js";
import { createWaSocket, logoutWeb, waitForWaConnection } from "./session.js"; import {
createWaSocket,
logoutWeb,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
} from "./session.js";
vi.mock("./session.js", () => { vi.mock("./session.js", () => {
const createWaSocket = vi.fn( const createWaSocket = vi.fn(
@ -17,11 +22,13 @@ vi.mock("./session.js", () => {
const getStatusCode = vi.fn( const getStatusCode = vi.fn(
(err: unknown) => (err: unknown) =>
(err as { output?: { statusCode?: number } })?.output?.statusCode ?? (err as { output?: { statusCode?: number } })?.output?.statusCode ??
(err as { status?: number })?.status, (err as { status?: number })?.status ??
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode,
); );
const webAuthExists = vi.fn(async () => false); const webAuthExists = vi.fn(async () => false);
const readWebSelfId = vi.fn(() => ({ e164: null, jid: null })); const readWebSelfId = vi.fn(() => ({ e164: null, jid: null }));
const logoutWeb = vi.fn(async () => true); const logoutWeb = vi.fn(async () => true);
const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {});
return { return {
createWaSocket, createWaSocket,
waitForWaConnection, waitForWaConnection,
@ -30,6 +37,7 @@ vi.mock("./session.js", () => {
webAuthExists, webAuthExists,
readWebSelfId, readWebSelfId,
logoutWeb, logoutWeb,
waitForCredsSaveQueueWithTimeout,
}; };
}); });
@ -39,22 +47,43 @@ vi.mock("./qr-image.js", () => ({
const createWaSocketMock = vi.mocked(createWaSocket); const createWaSocketMock = vi.mocked(createWaSocket);
const waitForWaConnectionMock = vi.mocked(waitForWaConnection); const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout);
const logoutWebMock = vi.mocked(logoutWeb); const logoutWebMock = vi.mocked(logoutWeb);
async function flushTasks() {
await Promise.resolve();
await Promise.resolve();
}
describe("login-qr", () => { describe("login-qr", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("restarts login once on status 515 and completes", async () => { it("restarts login once on status 515 and completes", async () => {
let releaseCredsFlush: (() => void) | undefined;
const credsFlushGate = new Promise<void>((resolve) => {
releaseCredsFlush = resolve;
});
waitForWaConnectionMock waitForWaConnectionMock
.mockRejectedValueOnce({ output: { statusCode: 515 } }) // Baileys v7 wraps the error: { error: BoomError(515) }
.mockRejectedValueOnce({ error: { output: { statusCode: 515 } } })
.mockResolvedValueOnce(undefined); .mockResolvedValueOnce(undefined);
waitForCredsSaveQueueWithTimeoutMock.mockReturnValueOnce(credsFlushGate);
const start = await startWebLoginWithQr({ timeoutMs: 5000 }); const start = await startWebLoginWithQr({ timeoutMs: 5000 });
expect(start.qrDataUrl).toBe("data:image/png;base64,base64"); expect(start.qrDataUrl).toBe("data:image/png;base64,base64");
const result = await waitForWebLogin({ timeoutMs: 5000 }); const resultPromise = waitForWebLogin({ timeoutMs: 5000 });
await flushTasks();
await flushTasks();
expect(createWaSocketMock).toHaveBeenCalledTimes(1);
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledOnce();
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith(expect.any(String));
releaseCredsFlush?.();
const result = await resultPromise;
expect(result.connected).toBe(true); expect(result.connected).toBe(true);
expect(createWaSocketMock).toHaveBeenCalledTimes(2); expect(createWaSocketMock).toHaveBeenCalledTimes(2);

View File

@ -12,6 +12,7 @@ import {
getStatusCode, getStatusCode,
logoutWeb, logoutWeb,
readWebSelfId, readWebSelfId,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection, waitForWaConnection,
webAuthExists, webAuthExists,
} from "./session.js"; } from "./session.js";
@ -85,9 +86,10 @@ async function restartLoginSocket(login: ActiveLogin, runtime: RuntimeEnv) {
} }
login.restartAttempted = true; login.restartAttempted = true;
runtime.log( runtime.log(
info("WhatsApp asked for a restart after pairing (code 515); retrying connection once…"), info("WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…"),
); );
closeSocket(login.sock); closeSocket(login.sock);
await waitForCredsSaveQueueWithTimeout(login.authDir);
try { try {
const sock = await createWaSocket(false, login.verbose, { const sock = await createWaSocket(false, login.verbose, {
authDir: login.authDir, authDir: login.authDir,

View File

@ -4,7 +4,12 @@ import path from "node:path";
import { DisconnectReason } from "@whiskeysockets/baileys"; import { DisconnectReason } from "@whiskeysockets/baileys";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { loginWeb } from "./login.js"; import { loginWeb } from "./login.js";
import { createWaSocket, formatError, waitForWaConnection } from "./session.js"; import {
createWaSocket,
formatError,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
} from "./session.js";
const rmMock = vi.spyOn(fs, "rm"); const rmMock = vi.spyOn(fs, "rm");
@ -35,10 +40,19 @@ vi.mock("./session.js", () => {
const createWaSocket = vi.fn(async () => (call++ === 0 ? sockA : sockB)); const createWaSocket = vi.fn(async () => (call++ === 0 ? sockA : sockB));
const waitForWaConnection = vi.fn(); const waitForWaConnection = vi.fn();
const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`); const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`);
const getStatusCode = vi.fn(
(err: unknown) =>
(err as { output?: { statusCode?: number } })?.output?.statusCode ??
(err as { status?: number })?.status ??
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode,
);
const waitForCredsSaveQueueWithTimeout = vi.fn(async () => {});
return { return {
createWaSocket, createWaSocket,
waitForWaConnection, waitForWaConnection,
formatError, formatError,
getStatusCode,
waitForCredsSaveQueueWithTimeout,
WA_WEB_AUTH_DIR: authDir, WA_WEB_AUTH_DIR: authDir,
logoutWeb: vi.fn(async (params: { authDir?: string }) => { logoutWeb: vi.fn(async (params: { authDir?: string }) => {
await fs.rm(params.authDir ?? authDir, { await fs.rm(params.authDir ?? authDir, {
@ -52,8 +66,14 @@ vi.mock("./session.js", () => {
const createWaSocketMock = vi.mocked(createWaSocket); const createWaSocketMock = vi.mocked(createWaSocket);
const waitForWaConnectionMock = vi.mocked(waitForWaConnection); const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
const waitForCredsSaveQueueWithTimeoutMock = vi.mocked(waitForCredsSaveQueueWithTimeout);
const formatErrorMock = vi.mocked(formatError); const formatErrorMock = vi.mocked(formatError);
async function flushTasks() {
await Promise.resolve();
await Promise.resolve();
}
describe("loginWeb coverage", () => { describe("loginWeb coverage", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
@ -65,12 +85,25 @@ describe("loginWeb coverage", () => {
}); });
it("restarts once when WhatsApp requests code 515", async () => { it("restarts once when WhatsApp requests code 515", async () => {
let releaseCredsFlush: (() => void) | undefined;
const credsFlushGate = new Promise<void>((resolve) => {
releaseCredsFlush = resolve;
});
waitForWaConnectionMock waitForWaConnectionMock
.mockRejectedValueOnce({ output: { statusCode: 515 } }) .mockRejectedValueOnce({ error: { output: { statusCode: 515 } } })
.mockResolvedValueOnce(undefined); .mockResolvedValueOnce(undefined);
waitForCredsSaveQueueWithTimeoutMock.mockReturnValueOnce(credsFlushGate);
const runtime = { log: vi.fn(), error: vi.fn() } as never; const runtime = { log: vi.fn(), error: vi.fn() } as never;
await loginWeb(false, waitForWaConnectionMock as never, runtime); const pendingLogin = loginWeb(false, waitForWaConnectionMock as never, runtime);
await flushTasks();
expect(createWaSocketMock).toHaveBeenCalledTimes(1);
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledOnce();
expect(waitForCredsSaveQueueWithTimeoutMock).toHaveBeenCalledWith(authDir);
releaseCredsFlush?.();
await pendingLogin;
expect(createWaSocketMock).toHaveBeenCalledTimes(2); expect(createWaSocketMock).toHaveBeenCalledTimes(2);
const firstSock = await createWaSocketMock.mock.results[0]?.value; const firstSock = await createWaSocketMock.mock.results[0]?.value;

View File

@ -5,7 +5,14 @@ import { danger, info, success } from "../../../src/globals.js";
import { logInfo } from "../../../src/logger.js"; import { logInfo } from "../../../src/logger.js";
import { defaultRuntime, type RuntimeEnv } from "../../../src/runtime.js"; import { defaultRuntime, type RuntimeEnv } from "../../../src/runtime.js";
import { resolveWhatsAppAccount } from "./accounts.js"; import { resolveWhatsAppAccount } from "./accounts.js";
import { createWaSocket, formatError, logoutWeb, waitForWaConnection } from "./session.js"; import {
createWaSocket,
formatError,
getStatusCode,
logoutWeb,
waitForCredsSaveQueueWithTimeout,
waitForWaConnection,
} from "./session.js";
export async function loginWeb( export async function loginWeb(
verbose: boolean, verbose: boolean,
@ -24,20 +31,17 @@ export async function loginWeb(
await wait(sock); await wait(sock);
console.log(success("✅ Linked! Credentials saved for future sends.")); console.log(success("✅ Linked! Credentials saved for future sends."));
} catch (err) { } catch (err) {
const code = const code = getStatusCode(err);
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode ??
(err as { output?: { statusCode?: number } })?.output?.statusCode;
if (code === 515) { if (code === 515) {
console.log( console.log(
info( info("WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…"),
"WhatsApp asked for a restart after pairing (code 515); creds are saved. Restarting connection once…",
),
); );
try { try {
sock.ws?.close(); sock.ws?.close();
} catch { } catch {
// ignore // ignore
} }
await waitForCredsSaveQueueWithTimeout(account.authDir);
const retry = await createWaSocket(false, verbose, { const retry = await createWaSocket(false, verbose, {
authDir: account.authDir, authDir: account.authDir,
}); });

View File

@ -0,0 +1,149 @@
import "./monitor-inbox.test-harness.js";
import { describe, expect, it, vi } from "vitest";
import { monitorWebInbox } from "./inbound.js";
import {
DEFAULT_ACCOUNT_ID,
getAuthDir,
getSock,
installWebMonitorInboxUnitTestHooks,
} from "./monitor-inbox.test-harness.js";
describe("append upsert handling (#20952)", () => {
installWebMonitorInboxUnitTestHooks();
type InboxOnMessage = NonNullable<Parameters<typeof monitorWebInbox>[0]["onMessage"]>;
async function tick() {
await new Promise((resolve) => setImmediate(resolve));
}
async function startInboxMonitor(onMessage: InboxOnMessage) {
const listener = await monitorWebInbox({
verbose: false,
onMessage,
accountId: DEFAULT_ACCOUNT_ID,
authDir: getAuthDir(),
});
return { listener, sock: getSock() };
}
it("processes recent append messages (within 60s of connect)", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// Timestamp ~5 seconds ago — recent, should be processed.
const recentTs = Math.floor(Date.now() / 1000) - 5;
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "recent-1", fromMe: false, remoteJid: "120363@g.us" },
message: { conversation: "hello from group" },
messageTimestamp: recentTs,
pushName: "Tester",
},
],
});
await tick();
expect(onMessage).toHaveBeenCalledTimes(1);
await listener.close();
});
it("skips stale append messages (older than 60s before connect)", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// Timestamp 5 minutes ago — stale history sync, should be skipped.
const staleTs = Math.floor(Date.now() / 1000) - 300;
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "stale-1", fromMe: false, remoteJid: "120363@g.us" },
message: { conversation: "old history sync" },
messageTimestamp: staleTs,
pushName: "OldTester",
},
],
});
await tick();
expect(onMessage).not.toHaveBeenCalled();
await listener.close();
});
it("skips append messages with NaN/non-finite timestamps", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// NaN timestamp should be treated as 0 (stale) and skipped.
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "nan-1", fromMe: false, remoteJid: "120363@g.us" },
message: { conversation: "bad timestamp" },
messageTimestamp: NaN,
pushName: "BadTs",
},
],
});
await tick();
expect(onMessage).not.toHaveBeenCalled();
await listener.close();
});
it("handles Long-like protobuf timestamps correctly", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// Baileys can deliver messageTimestamp as a Long object (from protobufjs).
// Number(longObj) calls valueOf() and returns the numeric value.
const recentTs = Math.floor(Date.now() / 1000) - 5;
const longLike = { low: recentTs, high: 0, unsigned: true, valueOf: () => recentTs };
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "long-1", fromMe: false, remoteJid: "120363@g.us" },
message: { conversation: "long timestamp" },
messageTimestamp: longLike,
pushName: "LongTs",
},
],
});
await tick();
expect(onMessage).toHaveBeenCalledTimes(1);
await listener.close();
});
it("always processes notify messages regardless of timestamp", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// Very old timestamp but type=notify — should always be processed.
const oldTs = Math.floor(Date.now() / 1000) - 86400;
sock.ev.emit("messages.upsert", {
type: "notify",
messages: [
{
key: { id: "notify-1", fromMe: false, remoteJid: "999@s.whatsapp.net" },
message: { conversation: "normal message" },
messageTimestamp: oldTs,
pushName: "User",
},
],
});
await tick();
expect(onMessage).toHaveBeenCalledTimes(1);
await listener.close();
});
});

View File

@ -204,6 +204,62 @@ describe("web session", () => {
expect(inFlight).toBe(0); expect(inFlight).toBe(0);
}); });
it("lets different authDir queues flush independently", async () => {
let inFlightA = 0;
let inFlightB = 0;
let releaseA: (() => void) | null = null;
let releaseB: (() => void) | null = null;
const gateA = new Promise<void>((resolve) => {
releaseA = resolve;
});
const gateB = new Promise<void>((resolve) => {
releaseB = resolve;
});
const saveCredsA = vi.fn(async () => {
inFlightA += 1;
await gateA;
inFlightA -= 1;
});
const saveCredsB = vi.fn(async () => {
inFlightB += 1;
await gateB;
inFlightB -= 1;
});
useMultiFileAuthStateMock
.mockResolvedValueOnce({
state: { creds: {} as never, keys: {} as never },
saveCreds: saveCredsA,
})
.mockResolvedValueOnce({
state: { creds: {} as never, keys: {} as never },
saveCreds: saveCredsB,
});
await createWaSocket(false, false, { authDir: "/tmp/wa-a" });
const sockA = getLastSocket();
await createWaSocket(false, false, { authDir: "/tmp/wa-b" });
const sockB = getLastSocket();
sockA.ev.emit("creds.update", {});
sockB.ev.emit("creds.update", {});
await flushCredsUpdate();
expect(saveCredsA).toHaveBeenCalledTimes(1);
expect(saveCredsB).toHaveBeenCalledTimes(1);
expect(inFlightA).toBe(1);
expect(inFlightB).toBe(1);
(releaseA as (() => void) | null)?.();
(releaseB as (() => void) | null)?.();
await flushCredsUpdate();
await flushCredsUpdate();
expect(inFlightA).toBe(0);
expect(inFlightB).toBe(0);
});
it("rotates creds backup when creds.json is valid JSON", async () => { it("rotates creds backup when creds.json is valid JSON", async () => {
const creds = mockCredsJsonSpies("{}"); const creds = mockCredsJsonSpies("{}");
const backupSuffix = path.join( const backupSuffix = path.join(

View File

@ -31,17 +31,24 @@ export {
webAuthExists, webAuthExists,
} from "./auth-store.js"; } from "./auth-store.js";
let credsSaveQueue: Promise<void> = Promise.resolve(); // Per-authDir queues so multi-account creds saves don't block each other.
const credsSaveQueues = new Map<string, Promise<void>>();
const CREDS_SAVE_FLUSH_TIMEOUT_MS = 15_000;
function enqueueSaveCreds( function enqueueSaveCreds(
authDir: string, authDir: string,
saveCreds: () => Promise<void> | void, saveCreds: () => Promise<void> | void,
logger: ReturnType<typeof getChildLogger>, logger: ReturnType<typeof getChildLogger>,
): void { ): void {
credsSaveQueue = credsSaveQueue const prev = credsSaveQueues.get(authDir) ?? Promise.resolve();
const next = prev
.then(() => safeSaveCreds(authDir, saveCreds, logger)) .then(() => safeSaveCreds(authDir, saveCreds, logger))
.catch((err) => { .catch((err) => {
logger.warn({ error: String(err) }, "WhatsApp creds save queue error"); logger.warn({ error: String(err) }, "WhatsApp creds save queue error");
})
.finally(() => {
if (credsSaveQueues.get(authDir) === next) credsSaveQueues.delete(authDir);
}); });
credsSaveQueues.set(authDir, next);
} }
async function safeSaveCreds( async function safeSaveCreds(
@ -186,10 +193,37 @@ export async function waitForWaConnection(sock: ReturnType<typeof makeWASocket>)
export function getStatusCode(err: unknown) { export function getStatusCode(err: unknown) {
return ( return (
(err as { output?: { statusCode?: number } })?.output?.statusCode ?? (err as { output?: { statusCode?: number } })?.output?.statusCode ??
(err as { status?: number })?.status (err as { status?: number })?.status ??
(err as { error?: { output?: { statusCode?: number } } })?.error?.output?.statusCode
); );
} }
/** Await pending credential saves — scoped to one authDir, or all if omitted. */
export function waitForCredsSaveQueue(authDir?: string): Promise<void> {
if (authDir) {
return credsSaveQueues.get(authDir) ?? Promise.resolve();
}
return Promise.all(credsSaveQueues.values()).then(() => {});
}
/** Await pending credential saves, but don't hang forever on stalled I/O. */
export async function waitForCredsSaveQueueWithTimeout(
authDir: string,
timeoutMs = CREDS_SAVE_FLUSH_TIMEOUT_MS,
): Promise<void> {
let flushTimeout: ReturnType<typeof setTimeout> | undefined;
await Promise.race([
waitForCredsSaveQueue(authDir),
new Promise<void>((resolve) => {
flushTimeout = setTimeout(resolve, timeoutMs);
}),
]).finally(() => {
if (flushTimeout) {
clearTimeout(flushTimeout);
}
});
}
function safeStringify(value: unknown, limit = 800): string { function safeStringify(value: unknown, limit = 800): string {
try { try {
const seen = new WeakSet(); const seen = new WeakSet();

View File

@ -15,8 +15,8 @@ import {
withResolvedWebhookRequestPipeline, withResolvedWebhookRequestPipeline,
WEBHOOK_ANOMALY_COUNTER_DEFAULTS, WEBHOOK_ANOMALY_COUNTER_DEFAULTS,
WEBHOOK_RATE_LIMIT_DEFAULTS, WEBHOOK_RATE_LIMIT_DEFAULTS,
resolveClientIp,
} from "openclaw/plugin-sdk/zalo"; } from "openclaw/plugin-sdk/zalo";
import { resolveClientIp } from "../../../src/gateway/net.js";
import type { ResolvedZaloAccount } from "./accounts.js"; import type { ResolvedZaloAccount } from "./accounts.js";
import type { ZaloFetch, ZaloUpdate } from "./api.js"; import type { ZaloFetch, ZaloUpdate } from "./api.js";
import type { ZaloRuntimeEnv } from "./monitor.js"; import type { ZaloRuntimeEnv } from "./monitor.js";

View File

@ -477,7 +477,37 @@ describe("zalouser monitor group mention gating", () => {
}); });
}); });
it("blocks group messages when sender is not in groupAllowFrom/allowFrom", async () => { it("allows allowlisted group replies without inheriting the DM allowlist", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false,
replyPayload: { text: "ok" },
});
await __testing.processMessage({
message: createGroupMessage({
content: "ping @bot",
hasAnyMention: true,
wasExplicitlyMentioned: true,
senderId: "456",
}),
account: {
...createAccount(),
config: {
...createAccount().config,
groupPolicy: "allowlist",
allowFrom: ["123"],
groups: {
"group:g-1": { allow: true, requireMention: true },
},
},
},
config: createConfig(),
runtime: createRuntimeEnv(),
});
expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
});
it("blocks group messages when sender is not in groupAllowFrom", async () => {
const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({ const { dispatchReplyWithBufferedBlockDispatcher } = installRuntime({
commandAuthorized: false, commandAuthorized: false,
}); });
@ -493,6 +523,7 @@ describe("zalouser monitor group mention gating", () => {
...createAccount().config, ...createAccount().config,
groupPolicy: "allowlist", groupPolicy: "allowlist",
allowFrom: ["999"], allowFrom: ["999"],
groupAllowFrom: ["999"],
}, },
}, },
config: createConfig(), config: createConfig(),

View File

@ -27,6 +27,7 @@ import {
resolveOpenProviderRuntimeGroupPolicy, resolveOpenProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy, resolveDefaultGroupPolicy,
resolveSenderCommandAuthorization, resolveSenderCommandAuthorization,
resolveSenderScopedGroupPolicy,
sendMediaWithLeadingCaption, sendMediaWithLeadingCaption,
summarizeMapping, summarizeMapping,
warnMissingProviderGroupPolicyFallbackOnce, warnMissingProviderGroupPolicyFallbackOnce,
@ -349,6 +350,10 @@ async function processMessage(
const dmPolicy = account.config.dmPolicy ?? "pairing"; const dmPolicy = account.config.dmPolicy ?? "pairing";
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v)); const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((v) => String(v)); const configGroupAllowFrom = (account.config.groupAllowFrom ?? []).map((v) => String(v));
const senderGroupPolicy = resolveSenderScopedGroupPolicy({
groupPolicy,
groupAllowFrom: configGroupAllowFrom,
});
const shouldComputeCommandAuth = core.channel.commands.shouldComputeCommandAuthorized( const shouldComputeCommandAuth = core.channel.commands.shouldComputeCommandAuthorized(
commandBody, commandBody,
config, config,
@ -360,10 +365,11 @@ async function processMessage(
const accessDecision = resolveDmGroupAccessWithLists({ const accessDecision = resolveDmGroupAccessWithLists({
isGroup, isGroup,
dmPolicy, dmPolicy,
groupPolicy, groupPolicy: senderGroupPolicy,
allowFrom: configAllowFrom, allowFrom: configAllowFrom,
groupAllowFrom: configGroupAllowFrom, groupAllowFrom: configGroupAllowFrom,
storeAllowFrom, storeAllowFrom,
groupAllowFromFallbackToAllowFrom: false,
isSenderAllowed: (allowFrom) => isSenderAllowed(senderId, allowFrom), isSenderAllowed: (allowFrom) => isSenderAllowed(senderId, allowFrom),
}); });
if (isGroup && accessDecision.decision !== "allow") { if (isGroup && accessDecision.decision !== "allow") {

View File

@ -88,6 +88,8 @@ fi
pnpm -s exec tsc -p "$A2UI_RENDERER_DIR/tsconfig.json" pnpm -s exec tsc -p "$A2UI_RENDERER_DIR/tsconfig.json"
if command -v rolldown >/dev/null 2>&1 && rolldown --version >/dev/null 2>&1; then if command -v rolldown >/dev/null 2>&1 && rolldown --version >/dev/null 2>&1; then
rolldown -c "$A2UI_APP_DIR/rolldown.config.mjs" rolldown -c "$A2UI_APP_DIR/rolldown.config.mjs"
elif [[ -f "$ROOT_DIR/node_modules/.pnpm/node_modules/rolldown/bin/cli.mjs" ]]; then
node "$ROOT_DIR/node_modules/.pnpm/node_modules/rolldown/bin/cli.mjs" -c "$A2UI_APP_DIR/rolldown.config.mjs"
elif [[ -f "$ROOT_DIR/node_modules/.pnpm/rolldown@1.0.0-rc.9/node_modules/rolldown/bin/cli.mjs" ]]; then elif [[ -f "$ROOT_DIR/node_modules/.pnpm/rolldown@1.0.0-rc.9/node_modules/rolldown/bin/cli.mjs" ]]; then
node "$ROOT_DIR/node_modules/.pnpm/rolldown@1.0.0-rc.9/node_modules/rolldown/bin/cli.mjs" \ node "$ROOT_DIR/node_modules/.pnpm/rolldown@1.0.0-rc.9/node_modules/rolldown/bin/cli.mjs" \
-c "$A2UI_APP_DIR/rolldown.config.mjs" -c "$A2UI_APP_DIR/rolldown.config.mjs"

View File

@ -113,6 +113,41 @@ function resolveRoute(route) {
return { ok: routes.has(current), terminal: current }; return { ok: routes.has(current), terminal: current };
} }
/** @param {unknown} node */
function collectNavPageEntries(node) {
/** @type {string[]} */
const entries = [];
if (Array.isArray(node)) {
for (const item of node) {
entries.push(...collectNavPageEntries(item));
}
return entries;
}
if (!node || typeof node !== "object") {
return entries;
}
const record = /** @type {Record<string, unknown>} */ (node);
if (Array.isArray(record.pages)) {
for (const page of record.pages) {
if (typeof page === "string") {
entries.push(page);
} else {
entries.push(...collectNavPageEntries(page));
}
}
}
for (const value of Object.values(record)) {
if (value !== record.pages) {
entries.push(...collectNavPageEntries(value));
}
}
return entries;
}
const markdownLinkRegex = /!?\[[^\]]*\]\(([^)]+)\)/g; const markdownLinkRegex = /!?\[[^\]]*\]\(([^)]+)\)/g;
/** @type {{file: string; line: number; link: string; reason: string}[]} */ /** @type {{file: string; line: number; link: string; reason: string}[]} */
@ -221,6 +256,22 @@ for (const abs of markdownFiles) {
} }
} }
for (const page of collectNavPageEntries(docsConfig.navigation || [])) {
checked++;
const route = normalizeRoute(page);
const resolvedRoute = resolveRoute(route);
if (resolvedRoute.ok) {
continue;
}
broken.push({
file: "docs.json",
line: 0,
link: page,
reason: `navigation page not published (terminal: ${resolvedRoute.terminal})`,
});
}
console.log(`checked_internal_links=${checked}`); console.log(`checked_internal_links=${checked}`);
console.log(`broken_links=${broken.length}`); console.log(`broken_links=${broken.length}`);

View File

@ -51,7 +51,7 @@ function replaceBlockLines(
} }
function renderKimiK2Ids(prefix: string) { function renderKimiK2Ids(prefix: string) {
return MOONSHOT_KIMI_K2_MODELS.map((model) => `- \`${prefix}${model.id}\``); return [...MOONSHOT_KIMI_K2_MODELS.map((model) => `- \`${prefix}${model.id}\``), ""];
} }
function renderMoonshotAliases() { function renderMoonshotAliases() {
@ -90,8 +90,8 @@ async function syncMoonshotDocs() {
let moonshotText = await readFile(moonshotDoc, "utf8"); let moonshotText = await readFile(moonshotDoc, "utf8");
moonshotText = replaceBlockLines( moonshotText = replaceBlockLines(
moonshotText, moonshotText,
"{/_ moonshot-kimi-k2-ids:start _/ && null}", '[//]: # "moonshot-kimi-k2-ids:start"',
"{/_ moonshot-kimi-k2-ids:end _/ && null}", '[//]: # "moonshot-kimi-k2-ids:end"',
renderKimiK2Ids(""), renderKimiK2Ids(""),
); );
moonshotText = replaceBlockLines( moonshotText = replaceBlockLines(
@ -110,8 +110,8 @@ async function syncMoonshotDocs() {
let conceptsText = await readFile(conceptsDoc, "utf8"); let conceptsText = await readFile(conceptsDoc, "utf8");
conceptsText = replaceBlockLines( conceptsText = replaceBlockLines(
conceptsText, conceptsText,
"{/_ moonshot-kimi-k2-model-refs:start _/ && null}", '[//]: # "moonshot-kimi-k2-model-refs:start"',
"{/_ moonshot-kimi-k2-model-refs:end _/ && null}", '[//]: # "moonshot-kimi-k2-model-refs:end"',
renderKimiK2Ids("moonshot/"), renderKimiK2Ids("moonshot/"),
); );

View File

@ -40,11 +40,11 @@ Use `remindctl` to manage Apple Reminders directly from the terminal.
❌ **DON'T use this skill when:** ❌ **DON'T use this skill when:**
- Scheduling Clawdbot tasks or alerts → use `cron` tool with systemEvent instead - Scheduling OpenClaw tasks or alerts → use `cron` tool with systemEvent instead
- Calendar events or appointments → use Apple Calendar - Calendar events or appointments → use Apple Calendar
- Project/work task management → use Notion, GitHub Issues, or task queue - Project/work task management → use Notion, GitHub Issues, or task queue
- One-time notifications → use `cron` tool for timed alerts - One-time notifications → use `cron` tool for timed alerts
- User says "remind me" but means a Clawdbot alert → clarify first - User says "remind me" but means an OpenClaw alert → clarify first
## Setup ## Setup
@ -112,7 +112,7 @@ Accepted by `--due` and date filters:
User: "Remind me to check on the deploy in 2 hours" User: "Remind me to check on the deploy in 2 hours"
**Ask:** "Do you want this in Apple Reminders (syncs to your phone) or as a Clawdbot alert (I'll message you here)?" **Ask:** "Do you want this in Apple Reminders (syncs to your phone) or as an OpenClaw alert (I'll message you here)?"
- Apple Reminders → use this skill - Apple Reminders → use this skill
- Clawdbot alert → use `cron` tool with systemEvent - OpenClaw alert → use `cron` tool with systemEvent

View File

@ -47,7 +47,7 @@ Use `imsg` to read and send iMessage/SMS via macOS Messages.app.
- Slack messages → use `slack` skill - Slack messages → use `slack` skill
- Group chat management (adding/removing members) → not supported - Group chat management (adding/removing members) → not supported
- Bulk/mass messaging → always confirm with user first - Bulk/mass messaging → always confirm with user first
- Replying in current conversation → just reply normally (Clawdbot routes automatically) - Replying in current conversation → just reply normally (OpenClaw routes automatically)
## Requirements ## Requirements

View File

@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vitest";
import type { AuthProfileStore } from "./auth-profiles/types.js";
const mocks = vi.hoisted(() => ({
readCodexCliCredentialsCached: vi.fn(),
readQwenCliCredentialsCached: vi.fn(() => null),
readMiniMaxCliCredentialsCached: vi.fn(() => null),
}));
vi.mock("./cli-credentials.js", () => ({
readCodexCliCredentialsCached: mocks.readCodexCliCredentialsCached,
readQwenCliCredentialsCached: mocks.readQwenCliCredentialsCached,
readMiniMaxCliCredentialsCached: mocks.readMiniMaxCliCredentialsCached,
}));
const { syncExternalCliCredentials } = await import("./auth-profiles/external-cli-sync.js");
const { CODEX_CLI_PROFILE_ID } = await import("./auth-profiles/constants.js");
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
describe("syncExternalCliCredentials", () => {
it("syncs Codex CLI credentials into the supported default auth profile", () => {
const expires = Date.now() + 60_000;
mocks.readCodexCliCredentialsCached.mockReturnValue({
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires,
accountId: "acct_123",
});
const store: AuthProfileStore = {
version: 1,
profiles: {},
};
const mutated = syncExternalCliCredentials(store);
expect(mutated).toBe(true);
expect(mocks.readCodexCliCredentialsCached).toHaveBeenCalledWith(
expect.objectContaining({ ttlMs: expect.any(Number) }),
);
expect(store.profiles[OPENAI_CODEX_DEFAULT_PROFILE_ID]).toMatchObject({
type: "oauth",
provider: "openai-codex",
access: "access-token",
refresh: "refresh-token",
expires,
accountId: "acct_123",
});
expect(store.profiles[CODEX_CLI_PROFILE_ID]).toBeUndefined();
});
});

View File

@ -1,4 +1,5 @@
import { import {
readCodexCliCredentialsCached,
readQwenCliCredentialsCached, readQwenCliCredentialsCached,
readMiniMaxCliCredentialsCached, readMiniMaxCliCredentialsCached,
} from "../cli-credentials.js"; } from "../cli-credentials.js";
@ -11,6 +12,8 @@ import {
} from "./constants.js"; } from "./constants.js";
import type { AuthProfileCredential, AuthProfileStore, OAuthCredential } from "./types.js"; import type { AuthProfileCredential, AuthProfileStore, OAuthCredential } from "./types.js";
const OPENAI_CODEX_DEFAULT_PROFILE_ID = "openai-codex:default";
function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean { function shallowEqualOAuthCredentials(a: OAuthCredential | undefined, b: OAuthCredential): boolean {
if (!a) { if (!a) {
return false; return false;
@ -37,7 +40,11 @@ function isExternalProfileFresh(cred: AuthProfileCredential | undefined, now: nu
if (cred.type !== "oauth" && cred.type !== "token") { if (cred.type !== "oauth" && cred.type !== "token") {
return false; return false;
} }
if (cred.provider !== "qwen-portal" && cred.provider !== "minimax-portal") { if (
cred.provider !== "qwen-portal" &&
cred.provider !== "minimax-portal" &&
cred.provider !== "openai-codex"
) {
return false; return false;
} }
if (typeof cred.expires !== "number") { if (typeof cred.expires !== "number") {
@ -82,7 +89,8 @@ function syncExternalCliCredentialsForProvider(
} }
/** /**
* Sync OAuth credentials from external CLI tools (Qwen Code CLI, MiniMax CLI) into the store. * Sync OAuth credentials from external CLI tools (Qwen Code CLI, MiniMax CLI, Codex CLI)
* into the store.
* *
* Returns true if any credentials were updated. * Returns true if any credentials were updated.
*/ */
@ -130,6 +138,17 @@ export function syncExternalCliCredentials(store: AuthProfileStore): boolean {
) { ) {
mutated = true; mutated = true;
} }
if (
syncExternalCliCredentialsForProvider(
store,
OPENAI_CODEX_DEFAULT_PROFILE_ID,
"openai-codex",
() => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }),
now,
)
) {
mutated = true;
}
return mutated; return mutated;
} }

View File

@ -90,6 +90,20 @@ describe("lookupContextTokens", () => {
} }
}); });
it("skips eager warmup for logs commands that do not need model metadata at startup", async () => {
const loadConfigMock = vi.fn(() => ({ models: {} }));
mockContextModuleDeps(loadConfigMock);
const argvSnapshot = process.argv;
process.argv = ["node", "openclaw", "logs", "--limit", "5"];
try {
await import("./context.js");
expect(loadConfigMock).not.toHaveBeenCalled();
} finally {
process.argv = argvSnapshot;
}
});
it("retries config loading after backoff when an initial load fails", async () => { it("retries config loading after backoff when an initial load fails", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const loadConfigMock = vi const loadConfigMock = vi

View File

@ -108,9 +108,24 @@ function getCommandPathFromArgv(argv: string[]): string[] {
return tokens; return tokens;
} }
const SKIP_EAGER_WARMUP_PRIMARY_COMMANDS = new Set([
"backup",
"completion",
"config",
"directory",
"doctor",
"health",
"hooks",
"logs",
"plugins",
"secrets",
"update",
"webhooks",
]);
function shouldSkipEagerContextWindowWarmup(argv: string[] = process.argv): boolean { function shouldSkipEagerContextWindowWarmup(argv: string[] = process.argv): boolean {
const [primary, secondary] = getCommandPathFromArgv(argv); const [primary] = getCommandPathFromArgv(argv);
return primary === "config" && secondary === "validate"; return primary ? SKIP_EAGER_WARMUP_PRIMARY_COMMANDS.has(primary) : false;
} }
function primeConfiguredContextWindows(): OpenClawConfig | undefined { function primeConfiguredContextWindows(): OpenClawConfig | undefined {

View File

@ -28,6 +28,10 @@ function supportsUsageInStreaming(model: Model<Api>): boolean | undefined {
?.supportsUsageInStreaming; ?.supportsUsageInStreaming;
} }
function supportsStrictMode(model: Model<Api>): boolean | undefined {
return (model.compat as { supportsStrictMode?: boolean } | undefined)?.supportsStrictMode;
}
function createTemplateModel(provider: string, id: string): Model<Api> { function createTemplateModel(provider: string, id: string): Model<Api> {
return { return {
id, id,
@ -86,6 +90,21 @@ function expectSupportsDeveloperRoleForcedOff(overrides?: Partial<Model<Api>>):
const normalized = normalizeModelCompat(model as Model<Api>); const normalized = normalizeModelCompat(model as Model<Api>);
expect(supportsDeveloperRole(normalized)).toBe(false); expect(supportsDeveloperRole(normalized)).toBe(false);
} }
function expectSupportsUsageInStreamingForcedOff(overrides?: Partial<Model<Api>>): void {
const model = { ...baseModel(), ...overrides };
delete (model as { compat?: unknown }).compat;
const normalized = normalizeModelCompat(model as Model<Api>);
expect(supportsUsageInStreaming(normalized)).toBe(false);
}
function expectSupportsStrictModeForcedOff(overrides?: Partial<Model<Api>>): void {
const model = { ...baseModel(), ...overrides };
delete (model as { compat?: unknown }).compat;
const normalized = normalizeModelCompat(model as Model<Api>);
expect(supportsStrictMode(normalized)).toBe(false);
}
function expectResolvedForwardCompat( function expectResolvedForwardCompat(
model: Model<Api> | undefined, model: Model<Api> | undefined,
expected: { provider: string; id: string }, expected: { provider: string; id: string },
@ -211,16 +230,22 @@ describe("normalizeModelCompat", () => {
}); });
}); });
it("leaves supportsUsageInStreaming at default for generic custom openai-completions provider", () => { it("forces supportsUsageInStreaming off for generic custom openai-completions provider", () => {
const model = { expectSupportsUsageInStreamingForcedOff({
...baseModel(),
provider: "custom-cpa", provider: "custom-cpa",
baseUrl: "https://cpa.example.com/v1", baseUrl: "https://cpa.example.com/v1",
}; });
delete (model as { compat?: unknown }).compat; });
const normalized = normalizeModelCompat(model as Model<Api>);
// supportsUsageInStreaming is no longer forced off — pi-ai's default (true) applies it("forces supportsStrictMode off for z.ai models", () => {
expect(supportsUsageInStreaming(normalized)).toBeUndefined(); expectSupportsStrictModeForcedOff();
});
it("forces supportsStrictMode off for custom openai-completions provider", () => {
expectSupportsStrictModeForcedOff({
provider: "custom-cpa",
baseUrl: "https://cpa.example.com/v1",
});
}); });
it("forces supportsDeveloperRole off for Qwen proxy via openai-completions", () => { it("forces supportsDeveloperRole off for Qwen proxy via openai-completions", () => {
@ -270,7 +295,7 @@ describe("normalizeModelCompat", () => {
expect(supportsUsageInStreaming(normalized)).toBe(true); expect(supportsUsageInStreaming(normalized)).toBe(true);
}); });
it("forces supportsDeveloperRole off but leaves supportsUsageInStreaming unset for non-native endpoints", () => { it("still forces flags off when not explicitly set by user", () => {
const model = { const model = {
...baseModel(), ...baseModel(),
provider: "custom-cpa", provider: "custom-cpa",
@ -279,8 +304,19 @@ describe("normalizeModelCompat", () => {
delete (model as { compat?: unknown }).compat; delete (model as { compat?: unknown }).compat;
const normalized = normalizeModelCompat(model); const normalized = normalizeModelCompat(model);
expect(supportsDeveloperRole(normalized)).toBe(false); expect(supportsDeveloperRole(normalized)).toBe(false);
// supportsUsageInStreaming is no longer forced off — pi-ai default applies expect(supportsUsageInStreaming(normalized)).toBe(false);
expect(supportsUsageInStreaming(normalized)).toBeUndefined(); expect(supportsStrictMode(normalized)).toBe(false);
});
it("respects explicit supportsStrictMode true on non-native endpoints", () => {
const model = {
...baseModel(),
provider: "custom-cpa",
baseUrl: "https://proxy.example.com/v1",
compat: { supportsStrictMode: true },
};
const normalized = normalizeModelCompat(model);
expect(supportsStrictMode(normalized)).toBe(true);
}); });
it("does not mutate caller model when forcing supportsDeveloperRole off", () => { it("does not mutate caller model when forcing supportsDeveloperRole off", () => {
@ -294,17 +330,23 @@ describe("normalizeModelCompat", () => {
expect(normalized).not.toBe(model); expect(normalized).not.toBe(model);
expect(supportsDeveloperRole(model)).toBeUndefined(); expect(supportsDeveloperRole(model)).toBeUndefined();
expect(supportsUsageInStreaming(model)).toBeUndefined(); expect(supportsUsageInStreaming(model)).toBeUndefined();
expect(supportsStrictMode(model)).toBeUndefined();
expect(supportsDeveloperRole(normalized)).toBe(false); expect(supportsDeveloperRole(normalized)).toBe(false);
// supportsUsageInStreaming is not set by normalizeModelCompat — pi-ai default applies expect(supportsUsageInStreaming(normalized)).toBe(false);
expect(supportsUsageInStreaming(normalized)).toBeUndefined(); expect(supportsStrictMode(normalized)).toBe(false);
}); });
it("does not override explicit compat false", () => { it("does not override explicit compat false", () => {
const model = baseModel(); const model = baseModel();
model.compat = { supportsDeveloperRole: false, supportsUsageInStreaming: false }; model.compat = {
supportsDeveloperRole: false,
supportsUsageInStreaming: false,
supportsStrictMode: false,
};
const normalized = normalizeModelCompat(model); const normalized = normalizeModelCompat(model);
expect(supportsDeveloperRole(normalized)).toBe(false); expect(supportsDeveloperRole(normalized)).toBe(false);
expect(supportsUsageInStreaming(normalized)).toBe(false); expect(supportsUsageInStreaming(normalized)).toBe(false);
expect(supportsStrictMode(normalized)).toBe(false);
}); });
}); });

View File

@ -52,16 +52,12 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
return model; return model;
} }
// The `developer` role is an OpenAI-native behavior that most compatible // The `developer` role and stream usage chunks are OpenAI-native behaviors.
// backends reject. Force it off for non-native endpoints unless the user // Many OpenAI-compatible backends reject `developer` and/or emit usage-only
// has explicitly opted in via their model config. // chunks that break strict parsers expecting choices[0]. Additionally, the
// // `strict` boolean inside tools validation is rejected by several providers
// `supportsUsageInStreaming` is NOT forced off — most OpenAI-compatible // causing tool calls to be ignored. For non-native openai-completions endpoints,
// backends (DashScope, DeepSeek, Groq, Together, etc.) handle // default these compat flags off unless explicitly opted in.
// `stream_options: { include_usage: true }` correctly, and disabling it
// silently breaks usage/cost tracking for all non-native providers.
// Users can still opt out with `compat.supportsUsageInStreaming: false`
// if their backend rejects the parameter.
const compat = model.compat ?? undefined; const compat = model.compat ?? undefined;
// When baseUrl is empty the pi-ai library defaults to api.openai.com, so // When baseUrl is empty the pi-ai library defaults to api.openai.com, so
// leave compat unchanged and let default native behavior apply. // leave compat unchanged and let default native behavior apply.
@ -69,23 +65,31 @@ export function normalizeModelCompat(model: Model<Api>): Model<Api> {
if (!needsForce) { if (!needsForce) {
return model; return model;
} }
// Respect explicit user overrides.
const forcedDeveloperRole = compat?.supportsDeveloperRole === true; const forcedDeveloperRole = compat?.supportsDeveloperRole === true;
const forcedUsageStreaming = compat?.supportsUsageInStreaming === true;
if (forcedDeveloperRole) { const targetStrictMode = compat?.supportsStrictMode ?? false;
if (
compat?.supportsDeveloperRole !== undefined &&
compat?.supportsUsageInStreaming !== undefined &&
compat?.supportsStrictMode !== undefined
) {
return model; return model;
} }
// Only force supportsDeveloperRole off. Leave supportsUsageInStreaming // Return a new object — do not mutate the caller's model reference.
// at whatever the user set or pi-ai's default (true).
return { return {
...model, ...model,
compat: compat compat: compat
? { ? {
...compat, ...compat,
supportsDeveloperRole: false, supportsDeveloperRole: forcedDeveloperRole || false,
supportsUsageInStreaming: forcedUsageStreaming || false,
supportsStrictMode: targetStrictMode,
} }
: { supportsDeveloperRole: false }, : {
supportsDeveloperRole: false,
supportsUsageInStreaming: false,
supportsStrictMode: false,
},
} as typeof model; } as typeof model;
} }

View File

@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
compactWithSafetyTimeout, compactWithSafetyTimeout,
EMBEDDED_COMPACTION_TIMEOUT_MS, EMBEDDED_COMPACTION_TIMEOUT_MS,
resolveCompactionTimeoutMs,
} from "./pi-embedded-runner/compaction-safety-timeout.js"; } from "./pi-embedded-runner/compaction-safety-timeout.js";
describe("compactWithSafetyTimeout", () => { describe("compactWithSafetyTimeout", () => {
@ -42,4 +43,113 @@ describe("compactWithSafetyTimeout", () => {
).rejects.toBe(error); ).rejects.toBe(error);
expect(vi.getTimerCount()).toBe(0); expect(vi.getTimerCount()).toBe(0);
}); });
it("calls onCancel when compaction times out", async () => {
vi.useFakeTimers();
const onCancel = vi.fn();
const compactPromise = compactWithSafetyTimeout(() => new Promise<never>(() => {}), 30, {
onCancel,
});
const timeoutAssertion = expect(compactPromise).rejects.toThrow("Compaction timed out");
await vi.advanceTimersByTimeAsync(30);
await timeoutAssertion;
expect(onCancel).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
it("aborts early on external abort signal and calls onCancel once", async () => {
vi.useFakeTimers();
const controller = new AbortController();
const onCancel = vi.fn();
const reason = new Error("request timed out");
const compactPromise = compactWithSafetyTimeout(() => new Promise<never>(() => {}), 100, {
abortSignal: controller.signal,
onCancel,
});
const abortAssertion = expect(compactPromise).rejects.toBe(reason);
controller.abort(reason);
await abortAssertion;
expect(onCancel).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
it("ignores onCancel errors and still rejects with the timeout", async () => {
vi.useFakeTimers();
const compactPromise = compactWithSafetyTimeout(() => new Promise<never>(() => {}), 30, {
onCancel: () => {
throw new Error("abortCompaction failed");
},
});
const timeoutAssertion = expect(compactPromise).rejects.toThrow("Compaction timed out");
await vi.advanceTimersByTimeAsync(30);
await timeoutAssertion;
expect(vi.getTimerCount()).toBe(0);
});
});
describe("resolveCompactionTimeoutMs", () => {
it("returns default when config is undefined", () => {
expect(resolveCompactionTimeoutMs(undefined)).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
});
it("returns default when compaction config is missing", () => {
expect(resolveCompactionTimeoutMs({ agents: { defaults: {} } })).toBe(
EMBEDDED_COMPACTION_TIMEOUT_MS,
);
});
it("returns default when timeoutSeconds is not set", () => {
expect(
resolveCompactionTimeoutMs({ agents: { defaults: { compaction: { mode: "safeguard" } } } }),
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
});
it("converts timeoutSeconds to milliseconds", () => {
expect(
resolveCompactionTimeoutMs({
agents: { defaults: { compaction: { timeoutSeconds: 1800 } } },
}),
).toBe(1_800_000);
});
it("floors fractional seconds", () => {
expect(
resolveCompactionTimeoutMs({
agents: { defaults: { compaction: { timeoutSeconds: 120.7 } } },
}),
).toBe(120_000);
});
it("returns default for zero", () => {
expect(
resolveCompactionTimeoutMs({ agents: { defaults: { compaction: { timeoutSeconds: 0 } } } }),
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
});
it("returns default for negative values", () => {
expect(
resolveCompactionTimeoutMs({ agents: { defaults: { compaction: { timeoutSeconds: -5 } } } }),
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
});
it("returns default for NaN", () => {
expect(
resolveCompactionTimeoutMs({
agents: { defaults: { compaction: { timeoutSeconds: NaN } } },
}),
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
});
it("returns default for Infinity", () => {
expect(
resolveCompactionTimeoutMs({
agents: { defaults: { compaction: { timeoutSeconds: Infinity } } },
}),
).toBe(EMBEDDED_COMPACTION_TIMEOUT_MS);
});
}); });

View File

@ -2,6 +2,7 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, UserMessage, Usage } from "@mariozechner/pi-ai"; import type { AssistantMessage, UserMessage, Usage } from "@mariozechner/pi-ai";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { import {
expectOpenAIResponsesStrictSanitizeCall,
loadSanitizeSessionHistoryWithCleanMocks, loadSanitizeSessionHistoryWithCleanMocks,
makeMockSessionManager, makeMockSessionManager,
makeInMemorySessionManager, makeInMemorySessionManager,
@ -247,7 +248,24 @@ describe("sanitizeSessionHistory", () => {
expect(result).toEqual(mockMessages); expect(result).toEqual(mockMessages);
}); });
it("passes simple user-only history through for openai-completions", async () => { it("sanitizes tool call ids for OpenAI-compatible responses providers", async () => {
setNonGoogleModelApi();
await sanitizeSessionHistory({
messages: mockMessages,
modelApi: "openai-responses",
provider: "custom",
sessionManager: mockSessionManager,
sessionId: TEST_SESSION_ID,
});
expectOpenAIResponsesStrictSanitizeCall(
mockedHelpers.sanitizeSessionMessagesImages,
mockMessages,
);
});
it("sanitizes tool call ids for openai-completions", async () => {
setNonGoogleModelApi(); setNonGoogleModelApi();
const result = await sanitizeSessionHistory({ const result = await sanitizeSessionHistory({

View File

@ -14,6 +14,7 @@ const {
resolveMemorySearchConfigMock, resolveMemorySearchConfigMock,
resolveSessionAgentIdMock, resolveSessionAgentIdMock,
estimateTokensMock, estimateTokensMock,
sessionAbortCompactionMock,
} = vi.hoisted(() => { } = vi.hoisted(() => {
const contextEngineCompactMock = vi.fn(async () => ({ const contextEngineCompactMock = vi.fn(async () => ({
ok: true as boolean, ok: true as boolean,
@ -65,6 +66,7 @@ const {
})), })),
resolveSessionAgentIdMock: vi.fn(() => "main"), resolveSessionAgentIdMock: vi.fn(() => "main"),
estimateTokensMock: vi.fn((_message?: unknown) => 10), estimateTokensMock: vi.fn((_message?: unknown) => 10),
sessionAbortCompactionMock: vi.fn(),
}; };
}); });
@ -121,6 +123,7 @@ vi.mock("@mariozechner/pi-coding-agent", () => {
session.messages.splice(1); session.messages.splice(1);
return await sessionCompactImpl(); return await sessionCompactImpl();
}), }),
abortCompaction: sessionAbortCompactionMock,
dispose: vi.fn(), dispose: vi.fn(),
}; };
return { session }; return { session };
@ -151,6 +154,7 @@ vi.mock("../models-config.js", () => ({
})); }));
vi.mock("../model-auth.js", () => ({ vi.mock("../model-auth.js", () => ({
applyLocalNoAuthHeaderOverride: vi.fn((model: unknown) => model),
getApiKeyForModel: vi.fn(async () => ({ apiKey: "test", mode: "env" })), getApiKeyForModel: vi.fn(async () => ({ apiKey: "test", mode: "env" })),
resolveModelAuthMode: vi.fn(() => "env"), resolveModelAuthMode: vi.fn(() => "env"),
})); }));
@ -420,6 +424,7 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
resolveSessionAgentIdMock.mockReturnValue("main"); resolveSessionAgentIdMock.mockReturnValue("main");
estimateTokensMock.mockReset(); estimateTokensMock.mockReset();
estimateTokensMock.mockReturnValue(10); estimateTokensMock.mockReturnValue(10);
sessionAbortCompactionMock.mockReset();
unregisterApiProviders(getCustomApiRegistrySourceId("ollama")); unregisterApiProviders(getCustomApiRegistrySourceId("ollama"));
}); });
@ -772,6 +777,24 @@ describe("compactEmbeddedPiSessionDirect hooks", () => {
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
}); });
it("aborts in-flight compaction when the caller abort signal fires", async () => {
const controller = new AbortController();
sessionCompactImpl.mockImplementationOnce(() => new Promise<never>(() => {}));
const resultPromise = compactEmbeddedPiSessionDirect(
directCompactionArgs({
abortSignal: controller.signal,
}),
);
controller.abort(new Error("request timed out"));
const result = await resultPromise;
expect(result.ok).toBe(false);
expect(result.reason).toContain("request timed out");
expect(sessionAbortCompactionMock).toHaveBeenCalledTimes(1);
});
}); });
describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => { describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => {

View File

@ -76,7 +76,7 @@ import {
import { resolveTranscriptPolicy } from "../transcript-policy.js"; import { resolveTranscriptPolicy } from "../transcript-policy.js";
import { import {
compactWithSafetyTimeout, compactWithSafetyTimeout,
EMBEDDED_COMPACTION_TIMEOUT_MS, resolveCompactionTimeoutMs,
} from "./compaction-safety-timeout.js"; } from "./compaction-safety-timeout.js";
import { buildEmbeddedExtensionFactories } from "./extensions.js"; import { buildEmbeddedExtensionFactories } from "./extensions.js";
import { import {
@ -87,7 +87,7 @@ import {
import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js"; import { getDmHistoryLimitFromSessionKey, limitHistoryTurns } from "./history.js";
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
import { log } from "./logger.js"; import { log } from "./logger.js";
import { buildModelAliasLines, resolveModel } from "./model.js"; import { buildModelAliasLines, resolveModelAsync } from "./model.js";
import { buildEmbeddedSandboxInfo } from "./sandbox-info.js"; import { buildEmbeddedSandboxInfo } from "./sandbox-info.js";
import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js"; import { prewarmSessionFile, trackSessionManagerAccess } from "./session-manager-cache.js";
import { resolveEmbeddedRunSkillEntries } from "./skills-runtime.js"; import { resolveEmbeddedRunSkillEntries } from "./skills-runtime.js";
@ -143,6 +143,7 @@ export type CompactEmbeddedPiSessionParams = {
enqueue?: typeof enqueueCommand; enqueue?: typeof enqueueCommand;
extraSystemPrompt?: string; extraSystemPrompt?: string;
ownerNumbers?: string[]; ownerNumbers?: string[];
abortSignal?: AbortSignal;
}; };
type CompactionMessageMetrics = { type CompactionMessageMetrics = {
@ -423,7 +424,7 @@ export async function compactEmbeddedPiSessionDirect(
}; };
const agentDir = params.agentDir ?? resolveOpenClawAgentDir(); const agentDir = params.agentDir ?? resolveOpenClawAgentDir();
await ensureOpenClawModelsJson(params.config, agentDir); await ensureOpenClawModelsJson(params.config, agentDir);
const { model, error, authStorage, modelRegistry } = resolveModel( const { model, error, authStorage, modelRegistry } = await resolveModelAsync(
provider, provider,
modelId, modelId,
agentDir, agentDir,
@ -687,10 +688,11 @@ export async function compactEmbeddedPiSessionDirect(
}); });
const systemPromptOverride = createSystemPromptOverride(appendPrompt); const systemPromptOverride = createSystemPromptOverride(appendPrompt);
const compactionTimeoutMs = resolveCompactionTimeoutMs(params.config);
const sessionLock = await acquireSessionWriteLock({ const sessionLock = await acquireSessionWriteLock({
sessionFile: params.sessionFile, sessionFile: params.sessionFile,
maxHoldMs: resolveSessionLockMaxHoldFromTimeout({ maxHoldMs: resolveSessionLockMaxHoldFromTimeout({
timeoutMs: EMBEDDED_COMPACTION_TIMEOUT_MS, timeoutMs: compactionTimeoutMs,
}), }),
}); });
try { try {
@ -915,8 +917,15 @@ export async function compactEmbeddedPiSessionDirect(
// If token estimation throws on a malformed message, fall back to 0 so // If token estimation throws on a malformed message, fall back to 0 so
// the sanity check below becomes a no-op instead of crashing compaction. // the sanity check below becomes a no-op instead of crashing compaction.
} }
const result = await compactWithSafetyTimeout(() => const result = await compactWithSafetyTimeout(
session.compact(params.customInstructions), () => session.compact(params.customInstructions),
compactionTimeoutMs,
{
abortSignal: params.abortSignal,
onCancel: () => {
session.abortCompaction();
},
},
); );
await runPostCompactionSideEffects({ await runPostCompactionSideEffects({
config: params.config, config: params.config,
@ -1064,7 +1073,12 @@ export async function compactEmbeddedPiSession(
const ceProvider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; const ceProvider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
const ceModelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL; const ceModelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
const agentDir = params.agentDir ?? resolveOpenClawAgentDir(); const agentDir = params.agentDir ?? resolveOpenClawAgentDir();
const { model: ceModel } = resolveModel(ceProvider, ceModelId, agentDir, params.config); const { model: ceModel } = await resolveModelAsync(
ceProvider,
ceModelId,
agentDir,
params.config,
);
const ceCtxInfo = resolveContextWindowInfo({ const ceCtxInfo = resolveContextWindowInfo({
cfg: params.config, cfg: params.config,
provider: ceProvider, provider: ceProvider,

View File

@ -1,10 +1,93 @@
import type { OpenClawConfig } from "../../config/config.js";
import { withTimeout } from "../../node-host/with-timeout.js"; import { withTimeout } from "../../node-host/with-timeout.js";
export const EMBEDDED_COMPACTION_TIMEOUT_MS = 300_000; export const EMBEDDED_COMPACTION_TIMEOUT_MS = 900_000;
const MAX_SAFE_TIMEOUT_MS = 2_147_000_000;
function createAbortError(signal: AbortSignal): Error {
const reason = "reason" in signal ? signal.reason : undefined;
if (reason instanceof Error) {
return reason;
}
const err = reason ? new Error("aborted", { cause: reason }) : new Error("aborted");
err.name = "AbortError";
return err;
}
export function resolveCompactionTimeoutMs(cfg?: OpenClawConfig): number {
const raw = cfg?.agents?.defaults?.compaction?.timeoutSeconds;
if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
return Math.min(Math.floor(raw) * 1000, MAX_SAFE_TIMEOUT_MS);
}
return EMBEDDED_COMPACTION_TIMEOUT_MS;
}
export async function compactWithSafetyTimeout<T>( export async function compactWithSafetyTimeout<T>(
compact: () => Promise<T>, compact: () => Promise<T>,
timeoutMs: number = EMBEDDED_COMPACTION_TIMEOUT_MS, timeoutMs: number = EMBEDDED_COMPACTION_TIMEOUT_MS,
opts?: {
abortSignal?: AbortSignal;
onCancel?: () => void;
},
): Promise<T> { ): Promise<T> {
return await withTimeout(() => compact(), timeoutMs, "Compaction"); let canceled = false;
const cancel = () => {
if (canceled) {
return;
}
canceled = true;
try {
opts?.onCancel?.();
} catch {
// Best-effort cancellation hook. Keep the timeout/abort path intact even
// if the underlying compaction cancel operation throws.
}
};
return await withTimeout(
async (timeoutSignal) => {
let timeoutListener: (() => void) | undefined;
let externalAbortListener: (() => void) | undefined;
let externalAbortPromise: Promise<never> | undefined;
const abortSignal = opts?.abortSignal;
if (timeoutSignal) {
timeoutListener = () => {
cancel();
};
timeoutSignal.addEventListener("abort", timeoutListener, { once: true });
}
if (abortSignal) {
if (abortSignal.aborted) {
cancel();
throw createAbortError(abortSignal);
}
externalAbortPromise = new Promise((_, reject) => {
externalAbortListener = () => {
cancel();
reject(createAbortError(abortSignal));
};
abortSignal.addEventListener("abort", externalAbortListener, { once: true });
});
}
try {
if (externalAbortPromise) {
return await Promise.race([compact(), externalAbortPromise]);
}
return await compact();
} finally {
if (timeoutListener) {
timeoutSignal?.removeEventListener("abort", timeoutListener);
}
if (externalAbortListener) {
abortSignal?.removeEventListener("abort", externalAbortListener);
}
}
},
timeoutMs,
"Compaction",
);
} }

View File

@ -5,8 +5,22 @@ vi.mock("../pi-model-discovery.js", () => ({
discoverModels: vi.fn(() => ({ find: vi.fn(() => null) })), discoverModels: vi.fn(() => ({ find: vi.fn(() => null) })),
})); }));
import type { OpenRouterModelCapabilities } from "./openrouter-model-capabilities.js";
const mockGetOpenRouterModelCapabilities = vi.fn<
(modelId: string) => OpenRouterModelCapabilities | undefined
>(() => undefined);
const mockLoadOpenRouterModelCapabilities = vi.fn<(modelId: string) => Promise<void>>(
async () => {},
);
vi.mock("./openrouter-model-capabilities.js", () => ({
getOpenRouterModelCapabilities: (modelId: string) => mockGetOpenRouterModelCapabilities(modelId),
loadOpenRouterModelCapabilities: (modelId: string) =>
mockLoadOpenRouterModelCapabilities(modelId),
}));
import type { OpenClawConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/config.js";
import { buildInlineProviderModels, resolveModel } from "./model.js"; import { buildInlineProviderModels, resolveModel, resolveModelAsync } from "./model.js";
import { import {
buildOpenAICodexForwardCompatExpectation, buildOpenAICodexForwardCompatExpectation,
makeModel, makeModel,
@ -17,6 +31,10 @@ import {
beforeEach(() => { beforeEach(() => {
resetMockDiscoverModels(); resetMockDiscoverModels();
mockGetOpenRouterModelCapabilities.mockReset();
mockGetOpenRouterModelCapabilities.mockReturnValue(undefined);
mockLoadOpenRouterModelCapabilities.mockReset();
mockLoadOpenRouterModelCapabilities.mockResolvedValue();
}); });
function buildForwardCompatTemplate(params: { function buildForwardCompatTemplate(params: {
@ -416,6 +434,107 @@ describe("resolveModel", () => {
}); });
}); });
it("uses OpenRouter API capabilities for unknown models when cache is populated", () => {
mockGetOpenRouterModelCapabilities.mockReturnValue({
name: "Healer Alpha",
input: ["text", "image"],
reasoning: true,
contextWindow: 262144,
maxTokens: 65536,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
});
const result = resolveModel("openrouter", "openrouter/healer-alpha", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "openrouter",
id: "openrouter/healer-alpha",
name: "Healer Alpha",
reasoning: true,
input: ["text", "image"],
contextWindow: 262144,
maxTokens: 65536,
});
});
it("falls back to text-only when OpenRouter API cache is empty", () => {
mockGetOpenRouterModelCapabilities.mockReturnValue(undefined);
const result = resolveModel("openrouter", "openrouter/healer-alpha", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "openrouter",
id: "openrouter/healer-alpha",
reasoning: false,
input: ["text"],
});
});
it("preloads OpenRouter capabilities before first async resolve of an unknown model", async () => {
mockLoadOpenRouterModelCapabilities.mockImplementation(async (modelId) => {
if (modelId === "google/gemini-3.1-flash-image-preview") {
mockGetOpenRouterModelCapabilities.mockReturnValue({
name: "Google: Nano Banana 2 (Gemini 3.1 Flash Image Preview)",
input: ["text", "image"],
reasoning: true,
contextWindow: 65536,
maxTokens: 65536,
cost: { input: 0.5, output: 3, cacheRead: 0, cacheWrite: 0 },
});
}
});
const result = await resolveModelAsync(
"openrouter",
"google/gemini-3.1-flash-image-preview",
"/tmp/agent",
);
expect(mockLoadOpenRouterModelCapabilities).toHaveBeenCalledWith(
"google/gemini-3.1-flash-image-preview",
);
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "openrouter",
id: "google/gemini-3.1-flash-image-preview",
reasoning: true,
input: ["text", "image"],
contextWindow: 65536,
maxTokens: 65536,
});
});
it("skips OpenRouter preload for models already present in the registry", async () => {
mockDiscoveredModel({
provider: "openrouter",
modelId: "openrouter/healer-alpha",
templateModel: {
id: "openrouter/healer-alpha",
name: "Healer Alpha",
api: "openai-completions",
provider: "openrouter",
baseUrl: "https://openrouter.ai/api/v1",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 65536,
},
});
const result = await resolveModelAsync("openrouter", "openrouter/healer-alpha", "/tmp/agent");
expect(mockLoadOpenRouterModelCapabilities).not.toHaveBeenCalled();
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "openrouter",
id: "openrouter/healer-alpha",
input: ["text", "image"],
});
});
it("prefers configured provider api metadata over discovered registry model", () => { it("prefers configured provider api metadata over discovered registry model", () => {
mockDiscoveredModel({ mockDiscoveredModel({
provider: "onehub", provider: "onehub",
@ -788,6 +907,27 @@ describe("resolveModel", () => {
); );
}); });
it("keeps suppressed openai gpt-5.3-codex-spark from falling through provider fallback", () => {
const cfg = {
models: {
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
api: "openai-responses",
models: [{ ...makeModel("gpt-4.1"), api: "openai-responses" }],
},
},
},
} as OpenClawConfig;
const result = resolveModel("openai", "gpt-5.3-codex-spark", "/tmp/agent", cfg);
expect(result.model).toBeUndefined();
expect(result.error).toBe(
"Unknown model: openai/gpt-5.3-codex-spark. gpt-5.3-codex-spark is only supported via openai-codex OAuth. Use openai-codex/gpt-5.3-codex-spark.",
);
});
it("rejects azure openai gpt-5.3-codex-spark with a codex-only hint", () => { it("rejects azure openai gpt-5.3-codex-spark with a codex-only hint", () => {
const result = resolveModel("azure-openai-responses", "gpt-5.3-codex-spark", "/tmp/agent"); const result = resolveModel("azure-openai-responses", "gpt-5.3-codex-spark", "/tmp/agent");

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