Compare commits

...

55 Commits

Author SHA1 Message Date
Peter Steinberger
b782ae104d fix: imessage echo detection ids (#8680) (thanks @Iranb) 2026-02-04 03:31:08 -08:00
iranb
27525586b2 fix(imessage): detect self-chat echoes to prevent infinite loops 2026-02-04 03:25:31 -08:00
Ayaan Zaidi
78fd194722 fix: telegram forward metadata + cron delivery guard (#8392) (thanks @Glucksberg) 2026-02-04 16:43:20 +05:30
Glucksberg
b2361292e7 fix: trim legacy signature fallback, type fromChatType as union 2026-02-04 16:43:20 +05:30
Glucksberg
57566c5e4d fix(telegram): include forward_from_chat metadata in forwarded message context (#8133)
Extract missing metadata from forwarded Telegram messages:

- Add fromChatType to TelegramForwardedContext, capturing the original
  chat type (channel/supergroup/group) from forward_from_chat.type
  and forward_origin.chat/sender_chat.type
- Add fromMessageId to capture the original message ID from channel forwards
- Read author_signature from forward_origin objects (modern API),
  preferring it over the deprecated forward_signature field
- Pass ForwardedFromChatType and ForwardedFromMessageId through to
  the inbound context payload
- Add test coverage for forward_origin channel/chat types, including
  author_signature extraction and fromChatType propagation
2026-02-04 16:43:20 +05:30
Christian Klotz
da6de49815 Telegram: use Grammy types directly, add typed Probe/Audit to plugin interface (#8403)
* Telegram: replace duplicated types with Grammy imports, add Probe/Audit generics to plugin interface

* Telegram: remove legacy forward metadata (deprecated in Bot API 7.0), simplify required-field checks

* Telegram: clean up remaining legacy references and unnecessary casts

* Telegram: keep RequestInit parameter type in proxy fetch (addresses review feedback)

* Telegram: add exhaustiveness guard to resolveForwardOrigin switch
2026-02-04 10:09:28 +00:00
Peter Steinberger
6341819d74 fix: cron announce delivery path (#8540) (thanks @tyler6204) 2026-02-04 01:03:59 -08:00
Tyler Yust
c396877dd9 Changelog: move cron entries to 2026.2.3 2026-02-04 01:03:59 -08:00
Tyler Yust
79d00e20db UI: handle future timestamps in formatAgo 2026-02-04 01:03:59 -08:00
Tyler Yust
f8d2534062 fix(cron): fix test failures and regenerate protocol files
- Add forceReload option to ensureLoaded to avoid stat I/O in normal
  paths while still detecting cross-service writes in the timer path
- Post isolated job summary back to main session (restores the old
  isolation.postToMainPrefix behavior via delivery model)
- Update legacy migration tests to check delivery.channel instead of
  payload.channel (normalization now moves delivery fields to top-level)
- Remove legacy deliver/channel/to/bestEffortDeliver from payload schema
- Update protocol conformance test for delivery modes
- Regenerate GatewayModels.swift (isolation -> delivery)
2026-02-04 01:03:59 -08:00
Tyler Yust
6fb8d8850e feat(cron): enhance legacy delivery handling in job patches
- Introduced logic to map legacy payload delivery updates onto the delivery object for `agentTurn` jobs, ensuring backward compatibility with legacy clients.
- Added tests to validate the correct application of legacy delivery settings in job patches, improving reliability in job configuration.
- Refactored delivery handling functions to streamline the merging of legacy delivery fields into the current job structure.

This update enhances the flexibility of delivery configurations, ensuring that legacy settings are properly handled in the context of new job patches.
2026-02-04 01:03:59 -08:00
Tyler Yust
246896d64b refactor(cron): improve delivery configuration handling in CronJobEditor and CLI
- Enhanced the delivery configuration logic in CronJobEditor to explicitly set the bestEffort property based on job settings.
- Refactored the CLI command to streamline delivery object creation, ensuring proper handling of optional fields like channel and to.
- Improved code readability and maintainability by restructuring delivery assignment logic.

This update clarifies the delivery configuration process, enhancing the reliability of job settings in both the editor and CLI.
2026-02-04 01:03:59 -08:00
Tyler Yust
64df61f697 feat(cron): enhance delivery handling and testing for isolated jobs
- Introduced new properties for explicit message targeting and message tool disabling in the EmbeddedRunAttemptParams type.
- Updated cron job tests to validate best-effort delivery behavior and handling of delivery failures.
- Added logic to clear delivery settings when switching session targets in cron jobs.
- Improved the resolution of delivery failures and best-effort logic in the isolated agent's run function.

This update enhances the flexibility and reliability of delivery mechanisms in isolated cron jobs, ensuring better handling of message delivery scenarios.
2026-02-04 01:03:59 -08:00
Tyler Yust
ef4949b936 refactor(cron): update delivery instructions for isolated agent
- Revised the delivery instructions in the isolated agent's command body to clarify that summaries should be returned as plain text and will be delivered by the main agent.
- Removed the previous directive regarding messaging tools to streamline communication guidelines.

This change enhances clarity in the delivery process for isolated agent tasks.
2026-02-04 01:03:59 -08:00
Tyler Yust
1409943863 feat(cron): set default enabled state for cron jobs
- Added logic to default the `enabled` property to `true` if not explicitly set as a boolean in the cron job input.
- Updated job creation and store functions to ensure consistent handling of the `enabled` state across the application.
- Enhanced input normalization to improve job configuration reliability.

This update ensures that cron jobs are enabled by default, enhancing user experience and reducing potential misconfigurations.
2026-02-04 01:03:59 -08:00
Tyler Yust
3f82daefd8 feat(cron): enhance delivery modes and job configuration
- Updated isolated cron jobs to support new delivery modes: `announce` and `none`, improving output management.
- Refactored job configuration to remove legacy fields and streamline delivery settings.
- Enhanced the `CronJobEditor` UI to reflect changes in delivery options, including a new segmented control for delivery mode selection.
- Updated documentation to clarify the new delivery configurations and their implications for job execution.
- Improved tests to validate the new delivery behavior and ensure backward compatibility with legacy settings.

This update provides users with greater flexibility in managing how isolated jobs deliver their outputs, enhancing overall usability and clarity in job configurations.
2026-02-04 01:03:59 -08:00
Tyler Yust
ab9f06f4ff feat(cron): enhance one-shot job behavior and CLI options
- Default one-shot jobs to delete after success, improving job management.
- Introduced `--keep-after-run` CLI option to allow users to retain one-shot jobs post-execution.
- Updated documentation to clarify default behaviors and new options for one-shot jobs.
- Adjusted cron job creation logic to ensure consistent handling of delete options.
- Enhanced tests to validate new behaviors and ensure reliability.

This update streamlines the handling of one-shot jobs, providing users with more control over job persistence and execution outcomes.
2026-02-04 01:03:59 -08:00
Tyler Yust
0bb0dfc9bc feat(cron): default isolated jobs to announce delivery and enhance scheduling options
- Updated isolated cron jobs to default to `announce` delivery mode, improving user experience.
- Enhanced scheduling options to accept ISO 8601 timestamps for `schedule.at`, while still supporting epoch milliseconds.
- Refined documentation to clarify delivery modes and scheduling formats.
- Adjusted related CLI commands and UI components to reflect these changes, ensuring consistency across the platform.
- Improved handling of legacy delivery fields for backward compatibility.

This update streamlines the configuration of isolated jobs, making it easier for users to manage job outputs and schedules.
2026-02-04 01:03:59 -08:00
Tyler Yust
511c656cbc feat(cron): introduce delivery modes for isolated jobs
- Added support for new delivery modes in cron jobs: `announce`, `deliver`, and `none`.
- Updated documentation to reflect changes in delivery options and usage examples.
- Enhanced the cron job schema to include delivery configuration.
- Refactored related CLI commands and UI components to accommodate the new delivery settings.
- Improved handling of legacy delivery fields for backward compatibility.

This update allows users to choose how output from isolated jobs is delivered, enhancing flexibility in job management.
2026-02-04 01:03:59 -08:00
Tyler Yust
3a03e38378 fix(cron): fix timeout, add timestamp validation, enable file sync
Fixes #7667

Task 1: Fix cron operation timeouts
- Increase default gateway tool timeout from 10s to 30s
- Increase cron-specific tool timeout to 60s
- Increase CLI default timeout from 10s to 30s
- Prevents timeouts when gateway is busy with long-running jobs

Task 2: Add timestamp validation
- New validateScheduleTimestamp() function in validate-timestamp.ts
- Rejects atMs timestamps more than 1 minute in the past
- Rejects atMs timestamps more than 10 years in the future
- Applied to both cron.add and cron.update operations
- Provides helpful error messages with current time and offset

Task 3: Enable file sync for manual edits
- Track file modification time (storeFileMtimeMs) in CronServiceState
- Check file mtime in ensureLoaded() and reload if changed
- Recompute next runs after reload to maintain accuracy
- Update mtime after persist() to prevent reload loop
- Dashboard now picks up manual edits to ~/.openclaw/cron/jobs.json
2026-02-04 01:03:59 -08:00
Peter Steinberger
a749db9820 fix: harden voice-call webhook verification 2026-02-03 23:47:27 -08:00
Val Alexander
fa4b28d7af feat: Add Docs Chat Widget with RAG-powered Q&A (#7908)
* feat: add docs chat prototype and related scripts

- Introduced a minimal documentation chatbot that builds a search index from markdown files and serves responses via an API.
- Added scripts for building the index and serving the chat API.
- Updated package.json with new commands for chat index building and serving.
- Created a new Vercel configuration file for deployment.
- Added a README for the docs chat prototype detailing usage and integration.

* feat: enhance docs chat with vector-based RAG pipeline

- Added vector index building and serving capabilities to the docs chat.
- Introduced new scripts for generating embeddings and serving the chat API using vector search.
- Updated package.json with new commands for vector index operations.
- Enhanced README with instructions for the new RAG pipeline and legacy keyword pipeline.
- Removed outdated Vercel configuration file.

* feat: enhance chat widget with markdown rendering and style updates

- Integrated dynamic loading of markdown rendering for chat responses.
- Implemented a fallback for markdown rendering to ensure consistent display.
- Updated CSS variables for improved theming and visual consistency.
- Enhanced chat bubble and input styles for better user experience.
- Added new styles for markdown content in chat bubbles, including code blocks and lists.

* feat: add copy buttons to chat widget for enhanced user interaction

- Implemented copy buttons for chat responses and code blocks in the chat widget.
- Updated CSS styles for improved visibility and interaction of copy buttons.
- Adjusted textarea height for better user experience.
- Enhanced functionality to allow users to easily copy text from chat bubbles and code snippets.

* feat: update chat widget styles for improved user experience

- Changed accent color for better visibility.
- Enhanced preformatted text styles for code blocks, including padding and word wrapping.
- Adjusted positioning and styles of copy buttons for chat responses and code snippets.
- Improved hover effects for copy buttons to enhance interactivity.

* feat: enhance chat widget styles for better responsiveness and scrollbar design

- Updated chat panel dimensions for improved adaptability on various screen sizes.
- Added custom scrollbar styles for better aesthetics and usability.
- Adjusted chat bubble styles for enhanced visibility and interaction.
- Improved layout for expanded chat widget on smaller screens.

* feat: refine chat widget code block styles and copy button functionality

- Adjusted padding and margin for preformatted text in chat responses for better visual consistency.
- Introduced a compact style for single-line code blocks to enhance layout.
- Updated copy button logic to skip short code blocks, improving user experience when copying code snippets.

* feat: add resize handle functionality to chat widget for adjustable panel width

- Implemented a draggable resize handle for the chat widget's sidebar, allowing users to adjust the panel width.
- Added CSS styles for the resize handle, including hover effects and responsive behavior.
- Integrated drag-to-resize logic to maintain user-set width across interactions.
- Ensured the panel resets to default width when closed, enhancing user experience.

* feat: implement rate limiting and error handling in chat API

- Added rate limiting functionality to the chat API, allowing a maximum number of requests per IP within a specified time window.
- Implemented error handling for rate limit exceeded responses, including appropriate headers and retry instructions.
- Enhanced error handling for other API errors, providing user-friendly messages for various failure scenarios.
- Updated README to include new environment variables for rate limiting configuration.

* feat: integrate Upstash Vector for enhanced document retrieval in chat API

- Implemented Upstash Vector as a cloud-based storage solution for document chunks, replacing the local LanceDB option.
- Added auto-detection of storage mode based on environment variables for seamless integration.
- Updated the chat API to utilize the new retrieval mechanism, enhancing response accuracy and performance.
- Enhanced README with setup instructions for Upstash and updated environment variable requirements.
- Introduced new scripts and configurations for managing the vector index and API interactions.

* feat: add create-markdown-preview.js for markdown rendering

- Introduced a new script for framework-agnostic HTML rendering of markdown content.
- The script includes various parsing functions to handle different markdown elements.
- Updated the chat widget to load the vendored version of @create-markdown/preview for improved markdown rendering.

* docs: update README for Upstash Vector index setup and environment variables

- Enhanced instructions for creating a Vector index in Upstash, including detailed settings and important notes.
- Clarified environment variable requirements for both Upstash and LanceDB modes.
- Improved formatting and organization of setup steps for better readability.
- Added health check and API endpoint details for clearer usage guidance.

* feat: add TRUST_PROXY environment variable for IP address handling

- Introduced the TRUST_PROXY variable to control the trust of X-Forwarded-For headers when behind a reverse proxy.
- Updated the README to document the new environment variable and its default value.
- Enhanced the getClientIP function to conditionally trust proxy headers based on the TRUST_PROXY setting.

* feat: add ALLOWED_ORIGINS environment variable for CORS configuration

- Introduced the ALLOWED_ORIGINS variable to specify allowed origins for CORS, enhancing security and flexibility.
- Updated the README to document the new environment variable and its usage.
- Refactored CORS handling in the server code to utilize the ALLOWED_ORIGINS setting for dynamic origin control.

* fix: ensure complete markdown rendering in chat widget

- Added logic to flush any remaining buffered bytes from the decoder, ensuring that all text is rendered correctly in the assistant bubble.
- Updated the assistant bubble's innerHTML to reflect the complete markdown content after streaming completes.

* feat: enhance DocsStore with improved vector handling and similarity conversion

- Added a constant for the distance metric used in vector searches, clarifying the assumption of L2 distance.
- Updated the createTable method to ensure all chunk properties are correctly mapped during table creation.
- Improved the similarity score calculation by providing a clear explanation of the conversion from L2 distance, ensuring accurate ranking of results.

* chore: fix code formatting

* Revert "chore: fix code formatting"

This reverts commit 6721f5b0b7.

* chore: format code for improved readability

- Reformatted code in serve.ts to enhance readability by adjusting indentation and line breaks.
- Ensured consistent style for function return types and object properties throughout the file.

* feat: Update API URL selection logic in chat widget

- Enhanced the API URL configuration to prioritize explicit settings, defaulting to localhost for development and using a production URL otherwise.
- Improved clarity in the code by adding comments to explain the logic behind the API URL selection.

* chore: Update documentation structure for improved organization

- Changed the path for the "Start Here" page to "start/index" for better clarity.
- Reformatted the "Web & Interfaces" and "Help" groups to use multi-line arrays for improved readability.

* feat: Enhance markdown preview integration and improve chat widget asset loading

- Wrapped the markdown preview functionality in an IIFE to expose a global API for easier integration.
- Updated the chat widget to load the markdown preview library dynamically, checking for existing instances to avoid duplicate loads.
- Adjusted asset paths in the chat widget to ensure correct loading based on the environment (local or production).
- Added CORS headers in the Vercel configuration for improved API accessibility.

* fix: Update chat API URL to include '/api' for correct endpoint access

- Modified the chat configuration and widget files to append '/api' to the API URL, ensuring proper endpoint usage in production and local environments.

* refactor: Simplify docs-chat configuration and remove unused scripts

- Removed outdated scripts and configurations related to the docs-chat feature, including build and serve scripts, as well as the associated package.json and README files.
- Streamlined the API URL configuration in the chat widget for better clarity and maintainability.
- Updated the package.json to remove unnecessary scripts related to the now-deleted functionality.

* refactor: Update documentation structure for improved clarity

- Changed the path for the "Start Here" page from "start/index" to "index" to enhance navigation and organization within the documentation.

* chore: Remove unused dependencies from package.json and pnpm-lock.yaml

- Deleted `@lancedb/lancedb`, `@upstash/vector`, and `openai` from both package.json and pnpm-lock.yaml to streamline the project and reduce bloat.

* chore: Clean up .gitignore by removing obsolete entries

- Deleted unused entries related to the docs-chat vector database from .gitignore to maintain a cleaner configuration.

* chore: Remove deprecated chat configuration and markdown preview script

- Deleted the `create-markdown-preview.js` script and the `docs-chat-config.js` file to eliminate unused assets and streamline the project.
- Updated the `docs-chat-widget.js` to directly reference the markdown library from a CDN, enhancing maintainability.

* chore: Update markdown rendering in chat widget to use marked library

- Replaced the deprecated `create-markdown-preview` library with the `marked` library for markdown rendering.
- Adjusted the script loading mechanism to fetch `marked` from a CDN, improving performance and maintainability.
- Enhanced the markdown rendering function to ensure security by disabling HTML pass-through and opening links in new tabs.

* Delete docs/start/index.md
2026-02-04 07:42:20 +00:00
Peter Steinberger
5292367324 docs: update Feishu plugin docs 2026-02-03 23:24:41 -08:00
Peter Steinberger
35eb40a700 fix(security): separate untrusted channel metadata from system prompt (thanks @KonstantinMirin) 2026-02-03 23:02:45 -08:00
Lucas Kim
6fdb136688 docs: document secure DM mode preset (#7872)
* docs: document secure DM mode preset

* fix: resolve merge conflict in resizable-divider
2026-02-04 06:55:13 +00:00
Peter Steinberger
44d1aa31f3 docs: add changelog for #7178 (thanks @Yeom-JinHo) 2026-02-03 22:47:21 -08:00
Yeom-JinHo
7b3d23b703 fix(control-ui): resolve header logo when gateway.controlUi.basePath is set (#7178)
* fix(control-ui): resolve header logo when gateway.controlUi.basePath is set

* refactor(control-ui): header logo under basePath; normalize logo URL with normalizeBasePath
2026-02-03 22:46:14 -08:00
Peter Steinberger
efc9d0a498 docs: note tmux send-keys TUI guidance (#7737) (thanks @Wangnov) 2026-02-03 22:04:27 -08:00
Wangnov
089d03453d docs(skills): split tmux send-keys for TUI (#7737)
* docs(skills): split tmux send-keys for TUI

* docs(skills): soften TUI send-keys wording

---------

Co-authored-by: wangnov <1694546283@qq.com>
2026-02-03 22:03:47 -08:00
Peter Steinberger
4a5d368926 fix: keep Moonshot CN base URL in onboarding (#7180) (thanks @waynelwz) 2026-02-03 21:58:51 -08:00
Liu Weizhan
1c6b25ddbb feat: add support for Moonshot API key for China endpoint 2026-02-03 21:58:51 -08:00
Peter Steinberger
9f16de2533 style: update chat new-messages button 2026-02-03 21:53:50 -08:00
Stephen Chen
d2ff28dda7 Make openclaw consistent in this file (#8533)
Co-authored-by: stephenchen2025 <schenjobs@gmail.com>
2026-02-04 00:02:25 -05:00
Michelle Tilley
f04e84f194 Address PR feedback 2026-02-04 04:02:38 +00:00
Michelle Tilley
5af322f710 feat(discord): add set-presence action for bot activity and status
Bridge the agent tools layer to the Discord gateway WebSocket via a new
gateway registry, allowing agents to set the bot's activity and online
status. Supports playing, streaming, listening, watching, custom, and
competing activity types. Custom type uses activityState as the sidebar
text; other types show activityName in the sidebar and activityState in
the flyout. Opt-in via channels.discord.actions.presence (default false).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 04:02:38 +00:00
Ayaan Zaidi
b64c1a56a1 chore: update changelog for #8193 (thanks @gildo) 2026-02-04 09:23:17 +05:30
Ayaan Zaidi
41a4f1200b fix: honor telegram model overrides in buttons (#8193) (thanks @gildo) 2026-02-04 09:23:17 +05:30
Ermenegildo Fiorito
202c554d09 Telegram: fix model button review issues
- Add currentModel to callback handler for checkmark display
- Add 64-byte callback_data limit protection (skip long model IDs)
- Add tests for large model lists and callback_data limits
2026-02-04 09:23:17 +05:30
Ermenegildo Fiorito
16349b6e93 Telegram: add inline button model selection for /models and /model commands 2026-02-04 09:23:17 +05:30
Tyler Yust
efb4a34be4 feat: add new messages indicator style for chat interface
- Introduced a floating pill element above the compose area to indicate new messages.
- Styled the indicator with hover effects and responsive design for better user interaction.
2026-02-03 19:32:50 -08:00
Peter Steinberger
e4b084c762 chore: bump version to 2026.2.3 2026-02-03 18:33:27 -08:00
Tyler Yust
3e6c623cfe refactor: remove unnecessary blank line in policy test file 2026-02-03 18:10:26 -08:00
Tyler Yust
9c4eab69cc iMessage: promote BlueBubbles and refresh docs/skills (#8415)
* feat: Make BlueBubbles the primary iMessage integration

- Remove old imsg skill (skills/imsg/SKILL.md)
- Create new BlueBubbles skill (skills/bluebubbles/SKILL.md) with message tool examples
- Add keep-alive script documentation for VM/headless setups to docs/channels/bluebubbles.md
  - AppleScript that pokes Messages.app every 5 minutes
  - LaunchAgent configuration for automatic execution
  - Prevents Messages.app from going idle in VM environments
- Update all documentation to prioritize BlueBubbles over legacy imsg:
  - Mark imsg channel as legacy throughout docs
  - Update README.md channel lists
  - Update wizard, hubs, pairing, and index docs
  - Update FAQ to recommend BlueBubbles for iMessage
  - Update RPC docs to note imsg as legacy pattern
  - Update Chinese documentation (zh-CN)
- Replace imsg examples with generic macOS skill examples where appropriate

BlueBubbles is now the recommended first-class iMessage integration,
with the legacy imsg integration marked for potential future removal.

* refactor: Update import paths and improve code formatting

- Adjusted import paths in session-status-tool.ts, whatsapp-heartbeat.ts, and heartbeat-runner.ts for consistency.
- Reformatted code for better readability by aligning and grouping related imports and function parameters.
- Enhanced error messages and conditional checks for clarity in heartbeat-runner.ts.

* skills: restore imsg skill and align bluebubbles skill

* docs: update FAQ for clarity and formatting

- Adjusted the formatting of the FAQ section to ensure consistent bullet point alignment.
- No content changes were made, only formatting improvements for better readability.

* style: oxfmt touched files

* fix: preserve BlueBubbles developer reference (#8415) (thanks @tyler6204)
2026-02-03 18:06:54 -08:00
Peter Steinberger
9c5941ba46 fix: add legacy daemon-cli shim for updates 2026-02-03 18:04:48 -08:00
Peter Steinberger
41d2993f7b fix(matrix): require unique allowlist matches in wizard 2026-02-03 18:04:02 -08:00
Peter Steinberger
d3ba57b7d7 feat: add configurable web_fetch maxChars cap 2026-02-03 18:03:53 -08:00
Peter Steinberger
6b4b6049b4 fix: enforce Nextcloud Talk allowlist by user id 2026-02-03 18:03:53 -08:00
Peter Steinberger
bbe9cb3022 fix(update): honor update.channel for update.run 2026-02-03 17:57:55 -08:00
Tak Hoffman
61a7fc5e0e Docs: drop healthcheck from bootstrap 2026-02-03 19:50:40 -06:00
Peter Steinberger
e895e85f54 fix: improve build-info resolution for commit/version 2026-02-03 17:31:51 -08:00
Peter Steinberger
e59eb814bd chore: bump version to 2026.2.2-1 2026-02-03 17:26:10 -08:00
Gustavo Madeira Santana
df55eeacdb Merge branch 'main' of https://github.com/openclaw/openclaw 2026-02-03 20:20:16 -05:00
Josh Palmer
fd8f8843bd Docs: guard zh-CN i18n workflow
What:
- document zh-CN docs pipeline and generated-doc guardrails
- note Discord escalation when the pipeline drags

Why:
- prevent accidental edits to generated translations

Tests:
- pnpm build
- pnpm check
- pnpm test

Co-authored-by: Josh Palmer <joshpalmer123@gmail.com>
2026-02-03 17:19:58 -08:00
Gustavo Madeira Santana
a9bb96ade3 fix: use build-info for version fallback 2026-02-03 20:19:32 -05:00
Peter Steinberger
f1cbe7db1d chore: add mac dSYM zip to release artifacts 2026-02-03 17:15:02 -08:00
205 changed files with 6816 additions and 1944 deletions

View File

@@ -28,6 +28,14 @@
- README (GitHub): keep absolute docs URLs (`https://docs.openclaw.ai/...`) so links work on GitHub.
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
## Docs i18n (zh-CN)
- `docs/zh-CN/**` is generated; do not edit unless the user explicitly asks.
- Pipeline: update English docs → adjust glossary (`docs/.i18n/glossary.zh-CN.json`) → run `scripts/docs-i18n` → apply targeted fixes only if instructed.
- Translation memory: `docs/.i18n/zh-CN.tm.jsonl` (generated).
- See `docs/.i18n/README.md`.
- The pipeline can be slow/inefficient; if its dragging, ping @jospalmbier on Discord instead of hacking around it.
## exe.dev VM ops (general)
- Access: stable path is `ssh exe.dev` then `ssh vm-name` (assume SSH key already set).

View File

@@ -2,21 +2,80 @@
Docs: https://docs.openclaw.ai
## 2026.2.3
### Changes
- Onboarding: add Moonshot (.cn) auth choice and keep the China base URL when preserving defaults. (#7180) Thanks @waynelwz.
- Docs: clarify tmux send-keys for TUI by splitting text and Enter. (#7737) Thanks @Wangnov.
- Cron: add announce delivery mode for isolated jobs (CLI + Control UI) and delivery mode config.
- Cron: default isolated jobs to announce delivery; accept ISO 8601 `schedule.at` in tool inputs.
- Cron: hard-migrate isolated jobs to announce/none delivery; drop legacy post-to-main/payload delivery fields and `atMs` inputs.
- Cron: delete one-shot jobs after success by default; add `--keep-after-run` for CLI.
- Cron: suppress messaging tools during announce delivery so summaries post consistently.
- Cron: avoid duplicate deliveries when isolated runs send messages directly.
### Fixes
- Telegram: honor session model overrides in inline model selection. (#8193) Thanks @gildo.
- iMessage: skip echo replies using recent outbound IDs before falling back to text matching. (#8680) Thanks @Iranb.
- Web UI: resolve header logo path when `gateway.controlUi.basePath` is set. (#7178) Thanks @Yeom-JinHo.
- Web UI: apply button styling to the new-messages indicator.
- Security: keep untrusted channel metadata out of system prompts (Slack/Discord). Thanks @KonstantinMirin.
- Voice call: harden webhook verification with host allowlists/proxy trust and keep ngrok loopback bypass.
- Cron: accept epoch timestamps and 0ms durations in CLI `--at` parsing.
- Cron: reload store data when the store file is recreated or mtime changes.
- Cron: deliver announce runs directly, honor delivery mode, and respect wakeMode for summaries. (#8540) Thanks @tyler6204.
- Telegram: include forward_from_chat metadata in forwarded messages and harden cron delivery target checks. (#8392) Thanks @Glucksberg.
## 2026.2.2-3
### Fixes
- Update: ship legacy daemon-cli shim for pre-tsdown update imports (fixes daemon restart after npm update).
## 2026.2.2-2
### Changes
- Docs: promote BlueBubbles as the recommended iMessage integration; mark imsg channel as legacy. (#8415) Thanks @tyler6204.
### Fixes
- CLI status: resolve build-info from bundled dist output (fixes "unknown" commit in npm builds).
## 2026.2.2-1
### Fixes
- CLI status: fall back to build-info for version detection (fixes "unknown" in beta builds). Thanks @gumadeira.
## 2026.2.2
### Changes
- Feishu: add Feishu/Lark plugin support + docs. (#7313) Thanks @jiulingyun (openclaw-cn).
- Web UI: add Agents dashboard for managing agent files, tools, skills, models, channels, and cron jobs.
- Subagents: discourage direct messaging tool use unless a specific external recipient is requested.
- Memory: implement the opt-in QMD backend for workspace memory. (#3160) Thanks @vignesh07.
- Security: add healthcheck skill and bootstrap audit guidance. (#7641) Thanks @Takhoffman.
- Config: allow setting a default subagent thinking level via `agents.defaults.subagents.thinking` (and per-agent `agents.list[].subagents.thinking`). (#7372) Thanks @tyler6204.
- Docs: zh-CN translations seed + polish, pipeline guidance, nav/landing updates, and typo fixes. (#8202, #6995, #6619, #7242, #7303, #7415) Thanks @AaronWander, @taiyi747, @Explorer1092, @rendaoyuan, @joshp123, @lailoo.
- Docs: add zh-CN i18n guardrails to avoid editing generated translations. (#8416) Thanks @joshp123.
### Fixes
- Security: require operator.approvals for gateway /approve commands. (#1) Thanks @mitsuhiko, @yueyueL.
- Security: Matrix allowlists now require full MXIDs; ambiguous name resolution no longer grants access. Thanks @MegaManSec.
- Docs: finish renaming the QMD memory docs to reference the OpenClaw state dir.
- Onboarding: keep TUI flow exclusive (skip completion prompt + background Web UI seed).
- Onboarding: drop completion prompt now handled by install/update.
- TUI: block onboarding output while TUI is active and restore terminal state on exit.
- CLI: cache shell completion scripts in state dir and source cached files in profiles.
- Zsh completion: escape option descriptions to avoid invalid option errors.
- Agents: repair malformed tool calls and session transcripts. (#7473) Thanks @justinhuangcode.
- fix(agents): validate AbortSignal instances before calling AbortSignal.any() (#7277) (thanks @Elarwei001)
- fix(webchat): respect user scroll position during streaming and refresh (#7226) (thanks @marcomarandiz)
- Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23.
- Media understanding: skip binary media from file text extraction. (#7475) Thanks @AlexZhangji.
- Security: enforce access-group gating for Slack slash commands when channel type lookup fails.
- Security: require validated shared-secret auth before skipping device identity on gateway connect.
- Security: guard skill installer downloads with SSRF checks (block private/localhost URLs).

View File

@@ -120,7 +120,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
## Highlights
- **[Local-first Gateway](https://docs.openclaw.ai/gateway)** — single control plane for sessions, channels, tools, and events.
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
- **[Multi-channel inbox](https://docs.openclaw.ai/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, BlueBubbles (iMessage), iMessage (legacy), Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
- **[Multi-agent routing](https://docs.openclaw.ai/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
- **[Voice Wake](https://docs.openclaw.ai/nodes/voicewake) + [Talk Mode](https://docs.openclaw.ai/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **[Live Canvas](https://docs.openclaw.ai/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.openclaw.ai/platforms/mac/canvas#canvas-a2ui).
@@ -144,7 +144,7 @@ Run `openclaw doctor` to surface risky/misconfigured DM policies.
### Channels
- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [iMessage](https://docs.openclaw.ai/channels/imessage) (imsg), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat).
- [Channels](https://docs.openclaw.ai/channels): [WhatsApp](https://docs.openclaw.ai/channels/whatsapp) (Baileys), [Telegram](https://docs.openclaw.ai/channels/telegram) (grammY), [Slack](https://docs.openclaw.ai/channels/slack) (Bolt), [Discord](https://docs.openclaw.ai/channels/discord) (discord.js), [Google Chat](https://docs.openclaw.ai/channels/googlechat) (Chat API), [Signal](https://docs.openclaw.ai/channels/signal) (signal-cli), [BlueBubbles](https://docs.openclaw.ai/channels/bluebubbles) (iMessage, recommended), [iMessage](https://docs.openclaw.ai/channels/imessage) (legacy imsg), [Microsoft Teams](https://docs.openclaw.ai/channels/msteams) (extension), [Matrix](https://docs.openclaw.ai/channels/matrix) (extension), [Zalo](https://docs.openclaw.ai/channels/zalo) (extension), [Zalo Personal](https://docs.openclaw.ai/channels/zalouser) (extension), [WebChat](https://docs.openclaw.ai/web/webchat).
- [Group routing](https://docs.openclaw.ai/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.openclaw.ai/channels).
### Apps + nodes
@@ -375,9 +375,15 @@ Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker
- Requires `signal-cli` and a `channels.signal` config section.
### [iMessage](https://docs.openclaw.ai/channels/imessage)
### [BlueBubbles (iMessage)](https://docs.openclaw.ai/channels/bluebubbles)
- macOS only; Messages must be signed in.
- **Recommended** iMessage integration.
- Configure `channels.bluebubbles.serverUrl` + `channels.bluebubbles.password` and a webhook (`channels.bluebubbles.webhookPath`).
- The BlueBubbles server runs on macOS; the Gateway can run on macOS or elsewhere.
### [iMessage (legacy)](https://docs.openclaw.ai/channels/imessage)
- Legacy macOS-only integration via `imsg` (Messages must be signed in).
- If `channels.imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.
### [Microsoft Teams](https://docs.openclaw.ai/channels/msteams)

View File

@@ -21,8 +21,8 @@ android {
applicationId = "ai.openclaw.android"
minSdk = 31
targetSdk = 36
versionCode = 202602020
versionName = "2026.2.2"
versionCode = 202602030
versionName = "2026.2.3"
}
buildTypes {

View File

@@ -19,7 +19,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.2</string>
<string>2026.2.3</string>
<key>CFBundleVersion</key>
<string>20260202</string>
<key>NSAppTransportSecurity</key>

View File

@@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.2</string>
<string>2026.2.3</string>
<key>CFBundleVersion</key>
<string>20260202</string>
</dict>

View File

@@ -81,7 +81,7 @@ targets:
properties:
CFBundleDisplayName: OpenClaw
CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.2.2"
CFBundleShortVersionString: "2026.2.3"
CFBundleVersion: "20260202"
UILaunchScreen: {}
UIApplicationSceneManifest:
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawTests
CFBundleShortVersionString: "2026.2.2"
CFBundleShortVersionString: "2026.2.3"
CFBundleVersion: "20260202"

View File

@@ -20,9 +20,11 @@ extension CronJobEditor {
self.wakeMode = job.wakeMode
switch job.schedule {
case let .at(atMs):
case let .at(at):
self.scheduleKind = .at
self.atDate = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
if let date = CronSchedule.parseAtDate(at) {
self.atDate = date
}
case let .every(everyMs, _):
self.scheduleKind = .every
self.everyText = self.formatDuration(ms: everyMs)
@@ -36,19 +38,22 @@ extension CronJobEditor {
case let .systemEvent(text):
self.payloadKind = .systemEvent
self.systemEventText = text
case let .agentTurn(message, thinking, timeoutSeconds, deliver, channel, to, bestEffortDeliver):
case let .agentTurn(message, thinking, timeoutSeconds, _, _, _, _):
self.payloadKind = .agentTurn
self.agentMessage = message
self.thinking = thinking ?? ""
self.timeoutSeconds = timeoutSeconds.map(String.init) ?? ""
self.deliver = deliver ?? false
let trimmed = (channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
self.channel = trimmed.isEmpty ? "last" : trimmed
self.to = to ?? ""
self.bestEffortDeliver = bestEffortDeliver ?? false
}
self.postPrefix = job.isolation?.postToMainPrefix ?? "Cron"
if let delivery = job.delivery {
self.deliveryMode = delivery.mode == .announce ? .announce : .none
let trimmed = (delivery.channel ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
self.channel = trimmed.isEmpty ? "last" : trimmed
self.to = delivery.to ?? ""
self.bestEffortDeliver = delivery.bestEffort ?? false
} else if self.sessionTarget == .isolated {
self.deliveryMode = .announce
}
}
func save() {
@@ -88,15 +93,29 @@ extension CronJobEditor {
}
if self.sessionTarget == .isolated {
let trimmed = self.postPrefix.trimmingCharacters(in: .whitespacesAndNewlines)
root["isolation"] = [
"postToMainPrefix": trimmed.isEmpty ? "Cron" : trimmed,
]
root["delivery"] = self.buildDelivery()
}
return root.mapValues { AnyCodable($0) }
}
func buildDelivery() -> [String: Any] {
let mode = self.deliveryMode == .announce ? "announce" : "none"
var delivery: [String: Any] = ["mode": mode]
if self.deliveryMode == .announce {
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
delivery["channel"] = trimmed.isEmpty ? "last" : trimmed
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { delivery["to"] = to }
if self.bestEffortDeliver {
delivery["bestEffort"] = true
} else if self.job?.delivery?.bestEffort == true {
delivery["bestEffort"] = false
}
}
return delivery
}
func trimmed(_ value: String) -> String {
value.trimmingCharacters(in: .whitespacesAndNewlines)
}
@@ -115,7 +134,7 @@ extension CronJobEditor {
func buildSchedule() throws -> [String: Any] {
switch self.scheduleKind {
case .at:
return ["kind": "at", "atMs": Int(self.atDate.timeIntervalSince1970 * 1000)]
return ["kind": "at", "at": CronSchedule.formatIsoDate(self.atDate)]
case .every:
guard let ms = Self.parseDurationMs(self.everyText) else {
throw NSError(
@@ -209,14 +228,6 @@ extension CronJobEditor {
let thinking = self.thinking.trimmingCharacters(in: .whitespacesAndNewlines)
if !thinking.isEmpty { payload["thinking"] = thinking }
if let n = Int(self.timeoutSeconds), n > 0 { payload["timeoutSeconds"] = n }
payload["deliver"] = self.deliver
if self.deliver {
let trimmed = self.channel.trimmingCharacters(in: .whitespacesAndNewlines)
payload["channel"] = trimmed.isEmpty ? "last" : trimmed
let to = self.to.trimmingCharacters(in: .whitespacesAndNewlines)
if !to.isEmpty { payload["to"] = to }
payload["bestEffortDeliver"] = self.bestEffortDeliver
}
return payload
}

View File

@@ -13,13 +13,12 @@ extension CronJobEditor {
self.payloadKind = .agentTurn
self.agentMessage = "Run diagnostic"
self.deliver = true
self.deliveryMode = .announce
self.channel = "last"
self.to = "+15551230000"
self.thinking = "low"
self.timeoutSeconds = "90"
self.bestEffortDeliver = true
self.postPrefix = "Cron"
_ = self.buildAgentTurnPayload()
_ = try? self.buildPayload()

View File

@@ -16,16 +16,13 @@ struct CronJobEditor: View {
+ "Use an isolated session for agent turns so your main chat stays clean."
static let sessionTargetNote =
"Main jobs post a system event into the current main session. "
+ "Isolated jobs run OpenClaw in a dedicated session and can deliver results (WhatsApp/Telegram/Discord/etc)."
+ "Isolated jobs run OpenClaw in a dedicated session and can announce results to a channel."
static let scheduleKindNote =
"“At” runs once, “Every” repeats with a duration, “Cron” uses a 5-field Unix expression."
static let isolatedPayloadNote =
"Isolated jobs always run an agent turn. The result can be delivered to a channel, "
+ "and a short summary is posted back to your main chat."
"Isolated jobs always run an agent turn. Announce sends a short summary to a channel."
static let mainPayloadNote =
"System events are injected into the current main session. Agent turns require an isolated session target."
static let mainSummaryNote =
"Controls the label used when posting the completion summary back to the main session."
@State var name: String = ""
@State var description: String = ""
@@ -46,13 +43,13 @@ struct CronJobEditor: View {
@State var payloadKind: PayloadKind = .systemEvent
@State var systemEventText: String = ""
@State var agentMessage: String = ""
@State var deliver: Bool = false
enum DeliveryChoice: String, CaseIterable, Identifiable { case announce, none; var id: String { rawValue } }
@State var deliveryMode: DeliveryChoice = .announce
@State var channel: String = "last"
@State var to: String = ""
@State var thinking: String = ""
@State var timeoutSeconds: String = ""
@State var bestEffortDeliver: Bool = false
@State var postPrefix: String = "Cron"
var channelOptions: [String] {
let ordered = self.channelsStore.orderedChannelIds()
@@ -248,27 +245,6 @@ struct CronJobEditor: View {
}
}
if self.sessionTarget == .isolated {
GroupBox("Main session summary") {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Prefix")
TextField("Cron", text: self.$postPrefix)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
}
GridRow {
Color.clear
.frame(width: self.labelColumnWidth, height: 1)
Text(
Self.mainSummaryNote)
.font(.footnote)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 2)
@@ -340,13 +316,17 @@ struct CronJobEditor: View {
.frame(width: 180, alignment: .leading)
}
GridRow {
self.gridLabel("Deliver")
Toggle("Deliver result to a channel", isOn: self.$deliver)
.toggleStyle(.switch)
self.gridLabel("Delivery")
Picker("", selection: self.$deliveryMode) {
Text("Announce summary").tag(DeliveryChoice.announce)
Text("None").tag(DeliveryChoice.none)
}
.labelsHidden()
.pickerStyle(.segmented)
}
}
if self.deliver {
if self.deliveryMode == .announce {
Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 14, verticalSpacing: 10) {
GridRow {
self.gridLabel("Channel")
@@ -367,7 +347,7 @@ struct CronJobEditor: View {
}
GridRow {
self.gridLabel("Best-effort")
Toggle("Do not fail the job if delivery fails", isOn: self.$bestEffortDeliver)
Toggle("Do not fail the job if announce fails", isOn: self.$bestEffortDeliver)
.toggleStyle(.switch)
}
}

View File

@@ -14,12 +14,26 @@ enum CronWakeMode: String, CaseIterable, Identifiable, Codable {
var id: String { self.rawValue }
}
enum CronDeliveryMode: String, CaseIterable, Identifiable, Codable {
case none
case announce
var id: String { self.rawValue }
}
struct CronDelivery: Codable, Equatable {
var mode: CronDeliveryMode
var channel: String?
var to: String?
var bestEffort: Bool?
}
enum CronSchedule: Codable, Equatable {
case at(atMs: Int)
case at(at: String)
case every(everyMs: Int, anchorMs: Int?)
case cron(expr: String, tz: String?)
enum CodingKeys: String, CodingKey { case kind, atMs, everyMs, anchorMs, expr, tz }
enum CodingKeys: String, CodingKey { case kind, at, atMs, everyMs, anchorMs, expr, tz }
var kind: String {
switch self {
@@ -34,7 +48,21 @@ enum CronSchedule: Codable, Equatable {
let kind = try container.decode(String.self, forKey: .kind)
switch kind {
case "at":
self = try .at(atMs: container.decode(Int.self, forKey: .atMs))
if let at = try container.decodeIfPresent(String.self, forKey: .at),
!at.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
{
self = .at(at: at)
return
}
if let atMs = try container.decodeIfPresent(Int.self, forKey: .atMs) {
let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
self = .at(at: Self.formatIsoDate(date))
return
}
throw DecodingError.dataCorruptedError(
forKey: .at,
in: container,
debugDescription: "Missing schedule.at")
case "every":
self = try .every(
everyMs: container.decode(Int.self, forKey: .everyMs),
@@ -55,8 +83,8 @@ enum CronSchedule: Codable, Equatable {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.kind, forKey: .kind)
switch self {
case let .at(atMs):
try container.encode(atMs, forKey: .atMs)
case let .at(at):
try container.encode(at, forKey: .at)
case let .every(everyMs, anchorMs):
try container.encode(everyMs, forKey: .everyMs)
try container.encodeIfPresent(anchorMs, forKey: .anchorMs)
@@ -65,6 +93,29 @@ enum CronSchedule: Codable, Equatable {
try container.encodeIfPresent(tz, forKey: .tz)
}
}
static func parseAtDate(_ value: String) -> Date? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
if let date = isoFormatterWithFractional.date(from: trimmed) { return date }
return isoFormatter.date(from: trimmed)
}
static func formatIsoDate(_ date: Date) -> String {
isoFormatter.string(from: date)
}
private static let isoFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
private static let isoFormatterWithFractional: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
}
enum CronPayload: Codable, Equatable {
@@ -131,10 +182,6 @@ enum CronPayload: Codable, Equatable {
}
}
struct CronIsolation: Codable, Equatable {
var postToMainPrefix: String?
}
struct CronJobState: Codable, Equatable {
var nextRunAtMs: Int?
var runningAtMs: Int?
@@ -157,7 +204,7 @@ struct CronJob: Identifiable, Codable, Equatable {
let sessionTarget: CronSessionTarget
let wakeMode: CronWakeMode
let payload: CronPayload
let isolation: CronIsolation?
let delivery: CronDelivery?
let state: CronJobState
var displayName: String {

View File

@@ -17,9 +17,11 @@ extension CronSettings {
func scheduleSummary(_ schedule: CronSchedule) -> String {
switch schedule {
case let .at(atMs):
let date = Date(timeIntervalSince1970: TimeInterval(atMs) / 1000)
return "at \(date.formatted(date: .abbreviated, time: .standard))"
case let .at(at):
if let date = CronSchedule.parseAtDate(at) {
return "at \(date.formatted(date: .abbreviated, time: .standard))"
}
return "at \(at)"
case let .every(everyMs, _):
return "every \(self.formatDuration(ms: everyMs))"
case let .cron(expr, tz):

View File

@@ -128,7 +128,7 @@ extension CronSettings {
.foregroundStyle(.orange)
.textSelection(.enabled)
}
self.payloadSummary(job.payload)
self.payloadSummary(job)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
@@ -205,7 +205,8 @@ extension CronSettings {
.padding(.vertical, 4)
}
func payloadSummary(_ payload: CronPayload) -> some View {
func payloadSummary(_ job: CronJob) -> some View {
let payload = job.payload
VStack(alignment: .leading, spacing: 6) {
Text("Payload")
.font(.caption.weight(.semibold))
@@ -215,7 +216,7 @@ extension CronSettings {
Text(text)
.font(.callout)
.textSelection(.enabled)
case let .agentTurn(message, thinking, timeoutSeconds, deliver, provider, to, _):
case let .agentTurn(message, thinking, timeoutSeconds, _, _, _, _):
VStack(alignment: .leading, spacing: 4) {
Text(message)
.font(.callout)
@@ -223,10 +224,19 @@ extension CronSettings {
HStack(spacing: 8) {
if let thinking, !thinking.isEmpty { StatusPill(text: "think \(thinking)", tint: .secondary) }
if let timeoutSeconds { StatusPill(text: "\(timeoutSeconds)s", tint: .secondary) }
if deliver ?? false {
StatusPill(text: "deliver", tint: .secondary)
if let provider, !provider.isEmpty { StatusPill(text: provider, tint: .secondary) }
if let to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
if job.sessionTarget == .isolated {
let delivery = job.delivery
if let delivery {
if delivery.mode == .announce {
StatusPill(text: "announce", tint: .secondary)
if let channel = delivery.channel, !channel.isEmpty {
StatusPill(text: channel, tint: .secondary)
}
if let to = delivery.to, !to.isEmpty { StatusPill(text: to, tint: .secondary) }
} else {
StatusPill(text: "no delivery", tint: .secondary)
}
}
}
}
}

View File

@@ -21,11 +21,11 @@ struct CronSettings_Previews: PreviewProvider {
message: "Summarize inbox",
thinking: "low",
timeoutSeconds: 600,
deliver: true,
channel: "last",
deliver: nil,
channel: nil,
to: nil,
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"),
bestEffortDeliver: nil),
delivery: CronDelivery(mode: .announce, channel: "last", to: nil, bestEffort: true),
state: CronJobState(
nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000),
runningAtMs: nil,
@@ -75,11 +75,11 @@ extension CronSettings {
message: "Summarize",
thinking: "low",
timeoutSeconds: 120,
deliver: true,
channel: "whatsapp",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "),
deliver: nil,
channel: nil,
to: nil,
bestEffortDeliver: nil),
delivery: CronDelivery(mode: .announce, channel: "whatsapp", to: "+15551234567", bestEffort: true),
state: CronJobState(
nextRunAtMs: 1_700_000_200_000,
runningAtMs: nil,
@@ -111,7 +111,7 @@ extension CronSettings {
_ = view.detailCard(job)
_ = view.runHistoryCard(job)
_ = view.runRow(run)
_ = view.payloadSummary(job.payload)
_ = view.payloadSummary(job)
_ = view.scheduleSummary(job.schedule)
_ = view.statusTint(job.state.lastStatus)
_ = view.nextRunLabel(Date())

View File

@@ -15,7 +15,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.2</string>
<string>2026.2.3</string>
<key>CFBundleVersion</key>
<string>202602020</string>
<key>CFBundleIconFile</key>

View File

@@ -1872,7 +1872,7 @@ public struct CronJob: Codable, Sendable {
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
public let isolation: [String: AnyCodable]?
public let delivery: [String: AnyCodable]?
public let state: [String: AnyCodable]
public init(
@@ -1888,7 +1888,7 @@ public struct CronJob: Codable, Sendable {
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
isolation: [String: AnyCodable]?,
delivery: [String: AnyCodable]?,
state: [String: AnyCodable]
) {
self.id = id
@@ -1903,7 +1903,7 @@ public struct CronJob: Codable, Sendable {
self.sessiontarget = sessiontarget
self.wakemode = wakemode
self.payload = payload
self.isolation = isolation
self.delivery = delivery
self.state = state
}
private enum CodingKeys: String, CodingKey {
@@ -1919,7 +1919,7 @@ public struct CronJob: Codable, Sendable {
case sessiontarget = "sessionTarget"
case wakemode = "wakeMode"
case payload
case isolation
case delivery
case state
}
}
@@ -1950,7 +1950,7 @@ public struct CronAddParams: Codable, Sendable {
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
public let isolation: [String: AnyCodable]?
public let delivery: [String: AnyCodable]?
public init(
name: String,
@@ -1962,7 +1962,7 @@ public struct CronAddParams: Codable, Sendable {
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
isolation: [String: AnyCodable]?
delivery: [String: AnyCodable]?
) {
self.name = name
self.agentid = agentid
@@ -1973,7 +1973,7 @@ public struct CronAddParams: Codable, Sendable {
self.sessiontarget = sessiontarget
self.wakemode = wakemode
self.payload = payload
self.isolation = isolation
self.delivery = delivery
}
private enum CodingKeys: String, CodingKey {
case name
@@ -1985,7 +1985,7 @@ public struct CronAddParams: Codable, Sendable {
case sessiontarget = "sessionTarget"
case wakemode = "wakeMode"
case payload
case isolation
case delivery
}
}

View File

@@ -40,11 +40,11 @@ struct CronJobEditorSmokeTests {
message: "Summarize the last day",
thinking: "low",
timeoutSeconds: 120,
deliver: true,
channel: "whatsapp",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "Cron"),
deliver: nil,
channel: nil,
to: nil,
bestEffortDeliver: nil),
delivery: CronDelivery(mode: .announce, channel: "whatsapp", to: "+15551234567", bestEffort: true),
state: CronJobState(
nextRunAtMs: 1_700_000_100_000,
runningAtMs: nil,

View File

@@ -5,12 +5,24 @@ import Testing
@Suite
struct CronModelsTests {
@Test func scheduleAtEncodesAndDecodes() throws {
let schedule = CronSchedule.at(atMs: 123)
let schedule = CronSchedule.at(at: "2026-02-03T18:00:00Z")
let data = try JSONEncoder().encode(schedule)
let decoded = try JSONDecoder().decode(CronSchedule.self, from: data)
#expect(decoded == schedule)
}
@Test func scheduleAtDecodesLegacyAtMs() throws {
let json = """
{"kind":"at","atMs":1700000000000}
"""
let decoded = try JSONDecoder().decode(CronSchedule.self, from: Data(json.utf8))
if case let .at(at) = decoded {
#expect(at.hasPrefix("2023-"))
} else {
#expect(Bool(false))
}
}
@Test func scheduleEveryEncodesAndDecodesWithAnchor() throws {
let schedule = CronSchedule.every(everyMs: 5000, anchorMs: 10000)
let data = try JSONEncoder().encode(schedule)
@@ -49,11 +61,11 @@ struct CronModelsTests {
deleteAfterRun: true,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .at(atMs: 1_700_000_000_000),
schedule: .at(at: "2026-02-03T18:00:00Z"),
sessionTarget: .main,
wakeMode: .now,
payload: .systemEvent(text: "ping"),
isolation: nil,
delivery: nil,
state: CronJobState())
let data = try JSONEncoder().encode(job)
let decoded = try JSONDecoder().decode(CronJob.self, from: data)
@@ -62,7 +74,7 @@ struct CronModelsTests {
@Test func scheduleDecodeRejectsUnknownKind() {
let json = """
{"kind":"wat","atMs":1}
{"kind":"wat","at":"2026-02-03T18:00:00Z"}
"""
#expect(throws: DecodingError.self) {
_ = try JSONDecoder().decode(CronSchedule.self, from: Data(json.utf8))
@@ -88,11 +100,11 @@ struct CronModelsTests {
deleteAfterRun: nil,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .at(atMs: 0),
schedule: .at(at: "2026-02-03T18:00:00Z"),
sessionTarget: .main,
wakeMode: .now,
payload: .systemEvent(text: "hi"),
isolation: nil,
delivery: nil,
state: CronJobState())
#expect(base.displayName == "hello")
@@ -111,11 +123,11 @@ struct CronModelsTests {
deleteAfterRun: nil,
createdAtMs: 0,
updatedAtMs: 0,
schedule: .at(atMs: 0),
schedule: .at(at: "2026-02-03T18:00:00Z"),
sessionTarget: .main,
wakeMode: .now,
payload: .systemEvent(text: "hi"),
isolation: nil,
delivery: nil,
state: CronJobState(
nextRunAtMs: 1_700_000_000_000,
runningAtMs: nil,

View File

@@ -23,7 +23,7 @@ struct SettingsViewSmokeTests {
sessionTarget: .main,
wakeMode: .now,
payload: .systemEvent(text: "ping"),
isolation: nil,
delivery: nil,
state: CronJobState(
nextRunAtMs: 1_700_000_200_000,
runningAtMs: nil,
@@ -48,11 +48,11 @@ struct SettingsViewSmokeTests {
message: "hello",
thinking: "low",
timeoutSeconds: 30,
deliver: true,
channel: "sms",
to: "+15551234567",
bestEffortDeliver: true),
isolation: CronIsolation(postToMainPrefix: "[cron] "),
deliver: nil,
channel: nil,
to: nil,
bestEffortDeliver: nil),
delivery: CronDelivery(mode: .announce, channel: "sms", to: "+15551234567", bestEffort: true),
state: CronJobState(
nextRunAtMs: nil,
runningAtMs: nil,

View File

@@ -1872,7 +1872,7 @@ public struct CronJob: Codable, Sendable {
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
public let isolation: [String: AnyCodable]?
public let delivery: [String: AnyCodable]?
public let state: [String: AnyCodable]
public init(
@@ -1888,7 +1888,7 @@ public struct CronJob: Codable, Sendable {
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
isolation: [String: AnyCodable]?,
delivery: [String: AnyCodable]?,
state: [String: AnyCodable]
) {
self.id = id
@@ -1903,7 +1903,7 @@ public struct CronJob: Codable, Sendable {
self.sessiontarget = sessiontarget
self.wakemode = wakemode
self.payload = payload
self.isolation = isolation
self.delivery = delivery
self.state = state
}
private enum CodingKeys: String, CodingKey {
@@ -1919,7 +1919,7 @@ public struct CronJob: Codable, Sendable {
case sessiontarget = "sessionTarget"
case wakemode = "wakeMode"
case payload
case isolation
case delivery
case state
}
}
@@ -1950,7 +1950,7 @@ public struct CronAddParams: Codable, Sendable {
public let sessiontarget: AnyCodable
public let wakemode: AnyCodable
public let payload: AnyCodable
public let isolation: [String: AnyCodable]?
public let delivery: [String: AnyCodable]?
public init(
name: String,
@@ -1962,7 +1962,7 @@ public struct CronAddParams: Codable, Sendable {
sessiontarget: AnyCodable,
wakemode: AnyCodable,
payload: AnyCodable,
isolation: [String: AnyCodable]?
delivery: [String: AnyCodable]?
) {
self.name = name
self.agentid = agentid
@@ -1973,7 +1973,7 @@ public struct CronAddParams: Codable, Sendable {
self.sessiontarget = sessiontarget
self.wakemode = wakemode
self.payload = payload
self.isolation = isolation
self.delivery = delivery
}
private enum CodingKeys: String, CodingKey {
case name
@@ -1985,7 +1985,7 @@ public struct CronAddParams: Codable, Sendable {
case sessiontarget = "sessionTarget"
case wakemode = "wakeMode"
case payload
case isolation
case delivery
}
}

View File

@@ -0,0 +1,667 @@
(() => {
if (document.getElementById("docs-chat-root")) return;
// Determine if we're on the docs site or embedded elsewhere
const hostname = window.location.hostname;
const isDocsSite = hostname === "localhost" || hostname === "127.0.0.1" ||
hostname.includes("docs.openclaw") || hostname.endsWith(".mintlify.app");
const assetsBase = isDocsSite ? "" : "https://docs.openclaw.ai";
const apiBase = "https://claw-api.openknot.ai/api";
// Load marked for markdown rendering (via CDN)
let markedReady = false;
const loadMarkdownLib = () => {
if (window.marked) {
markedReady = true;
return;
}
const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/marked@15.0.6/marked.min.js";
script.onload = () => {
if (window.marked) {
markedReady = true;
}
};
script.onerror = () => console.warn("Failed to load marked library");
document.head.appendChild(script);
};
loadMarkdownLib();
// Markdown renderer with fallback before module loads
const renderMarkdown = (text) => {
if (markedReady && window.marked) {
// Configure marked for security: disable HTML pass-through
const html = window.marked.parse(text, { async: false, gfm: true, breaks: true });
// Open links in new tab by rewriting <a> tags
return html.replace(/<a href="/g, '<a target="_blank" rel="noopener" href="');
}
// Fallback: escape HTML and preserve newlines
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\n/g, "<br>");
};
const style = document.createElement("style");
style.textContent = `
#docs-chat-root { position: fixed; right: 20px; bottom: 20px; z-index: 9999; font-family: var(--font-body, system-ui, -apple-system, sans-serif); }
#docs-chat-root.docs-chat-expanded { right: 0; bottom: 0; top: 0; }
/* Thin scrollbar styling */
#docs-chat-root ::-webkit-scrollbar { width: 6px; height: 6px; }
#docs-chat-root ::-webkit-scrollbar-track { background: transparent; }
#docs-chat-root ::-webkit-scrollbar-thumb { background: var(--docs-chat-panel-border); border-radius: 3px; }
#docs-chat-root ::-webkit-scrollbar-thumb:hover { background: var(--docs-chat-muted); }
#docs-chat-root * { scrollbar-width: thin; scrollbar-color: var(--docs-chat-panel-border) transparent; }
:root {
--docs-chat-accent: var(--accent, #ff7d60);
--docs-chat-text: #1a1a1a;
--docs-chat-muted: #555;
--docs-chat-panel: rgba(255, 255, 255, 0.92);
--docs-chat-panel-border: rgba(0, 0, 0, 0.1);
--docs-chat-surface: rgba(250, 250, 250, 0.95);
--docs-chat-shadow: 0 18px 50px rgba(0,0,0,0.15);
--docs-chat-code-bg: rgba(0, 0, 0, 0.05);
--docs-chat-assistant-bg: #f5f5f5;
}
html[data-theme="dark"] {
--docs-chat-text: #e8e8e8;
--docs-chat-muted: #aaa;
--docs-chat-panel: rgba(28, 28, 30, 0.95);
--docs-chat-panel-border: rgba(255, 255, 255, 0.12);
--docs-chat-surface: rgba(38, 38, 40, 0.95);
--docs-chat-shadow: 0 18px 50px rgba(0,0,0,0.5);
--docs-chat-code-bg: rgba(255, 255, 255, 0.08);
--docs-chat-assistant-bg: #2a2a2c;
}
#docs-chat-button {
display: inline-flex;
align-items: center;
gap: 10px;
background: linear-gradient(140deg, rgba(255,90,54,0.25), rgba(255,90,54,0.06));
color: var(--docs-chat-text);
border: 1px solid rgba(255,90,54,0.4);
border-radius: 999px;
padding: 10px 14px;
cursor: pointer;
box-shadow: 0 8px 30px rgba(255,90,54, 0.08);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
font-family: var(--font-pixel, var(--font-body, system-ui, sans-serif));
}
#docs-chat-button span { font-weight: 600; letter-spacing: 0.04em; font-size: 14px; }
.docs-chat-logo { width: 20px; height: 20px; }
#docs-chat-panel {
width: min(440px, calc(100vw - 40px));
height: min(696px, calc(100vh - 80px));
background: var(--docs-chat-panel);
color: var(--docs-chat-text);
border-radius: 16px;
border: 1px solid var(--docs-chat-panel-border);
box-shadow: var(--docs-chat-shadow);
display: none;
flex-direction: column;
overflow: hidden;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
#docs-chat-root.docs-chat-expanded #docs-chat-panel {
width: min(512px, 100vw);
height: 100vh;
height: 100dvh;
border-radius: 18px 0 0 18px;
padding-top: env(safe-area-inset-top, 0);
padding-bottom: env(safe-area-inset-bottom, 0);
}
@media (max-width: 520px) {
#docs-chat-root.docs-chat-expanded #docs-chat-panel {
width: 100vw;
border-radius: 0;
}
#docs-chat-root.docs-chat-expanded { right: 0; left: 0; bottom: 0; top: 0; }
}
#docs-chat-header {
padding: 12px 14px;
font-weight: 600;
font-family: var(--font-pixel, var(--font-body, system-ui, sans-serif));
letter-spacing: 0.03em;
border-bottom: 1px solid var(--docs-chat-panel-border);
display: flex;
justify-content: space-between;
align-items: center;
}
#docs-chat-header-title { display: inline-flex; align-items: center; gap: 8px; }
#docs-chat-header-title span { color: var(--docs-chat-text); font-size: 15px; }
#docs-chat-header-actions { display: inline-flex; align-items: center; gap: 6px; }
.docs-chat-icon-button {
border: 1px solid var(--docs-chat-panel-border);
background: transparent;
color: inherit;
border-radius: 8px;
width: 30px;
height: 30px;
cursor: pointer;
font-size: 16px;
line-height: 1;
}
#docs-chat-messages { flex: 1; padding: 12px 14px; overflow: auto; background: transparent; }
#docs-chat-input {
display: flex;
gap: 8px;
padding: 12px 14px;
border-top: 1px solid var(--docs-chat-panel-border);
background: var(--docs-chat-surface);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
}
#docs-chat-input textarea {
flex: 1;
resize: none;
border: 1px solid var(--docs-chat-panel-border);
border-radius: 10px;
padding: 9px 10px;
font-size: 14px;
line-height: 1.5;
font-family: inherit;
color: var(--docs-chat-text);
background: var(--docs-chat-surface);
min-height: 42px;
max-height: 120px;
overflow-y: auto;
}
#docs-chat-input textarea::placeholder { color: var(--docs-chat-muted); }
#docs-chat-send {
background: var(--docs-chat-accent);
color: #fff;
border: none;
border-radius: 10px;
padding: 8px 14px;
cursor: pointer;
font-weight: 600;
font-family: inherit;
font-size: 14px;
transition: opacity 0.15s ease;
}
#docs-chat-send:hover { opacity: 0.9; }
#docs-chat-send:active { opacity: 0.8; }
.docs-chat-bubble {
margin-bottom: 10px;
padding: 10px 14px;
border-radius: 12px;
font-size: 14px;
line-height: 1.6;
max-width: 92%;
}
.docs-chat-user {
background: rgba(255, 125, 96, 0.15);
color: var(--docs-chat-text);
border: 1px solid rgba(255, 125, 96, 0.3);
align-self: flex-end;
white-space: pre-wrap;
margin-left: auto;
}
html[data-theme="dark"] .docs-chat-user {
background: rgba(255, 125, 96, 0.18);
border-color: rgba(255, 125, 96, 0.35);
}
.docs-chat-assistant {
background: var(--docs-chat-assistant-bg);
color: var(--docs-chat-text);
border: 1px solid var(--docs-chat-panel-border);
}
/* Markdown content styling for chat bubbles */
.docs-chat-assistant p { margin: 0 0 10px 0; }
.docs-chat-assistant p:last-child { margin-bottom: 0; }
.docs-chat-assistant code {
background: var(--docs-chat-code-bg);
padding: 2px 6px;
border-radius: 5px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.9em;
}
.docs-chat-assistant pre {
background: var(--docs-chat-code-bg);
padding: 10px 12px;
border-radius: 8px;
overflow-x: auto;
margin: 6px 0;
font-size: 0.9em;
max-width: 100%;
white-space: pre;
word-wrap: normal;
}
.docs-chat-assistant pre::-webkit-scrollbar-thumb { background: transparent; }
.docs-chat-assistant pre:hover::-webkit-scrollbar-thumb { background: var(--docs-chat-panel-border); }
@media (hover: none) {
.docs-chat-assistant pre { -webkit-overflow-scrolling: touch; }
.docs-chat-assistant pre::-webkit-scrollbar-thumb { background: var(--docs-chat-panel-border); }
}
.docs-chat-assistant pre code {
background: transparent;
padding: 0;
font-size: inherit;
white-space: pre;
word-wrap: normal;
display: block;
}
/* Compact single-line code blocks */
.docs-chat-assistant pre.compact {
margin: 4px 0;
padding: 6px 10px;
}
/* Longer code blocks with copy button need extra top padding */
.docs-chat-assistant pre:not(.compact) {
padding-top: 28px;
}
.docs-chat-assistant a {
color: var(--docs-chat-accent);
text-decoration: underline;
text-underline-offset: 2px;
}
.docs-chat-assistant a:hover { opacity: 0.8; }
.docs-chat-assistant ul, .docs-chat-assistant ol {
margin: 8px 0;
padding-left: 18px;
list-style: none;
}
.docs-chat-assistant li {
margin: 4px 0;
position: relative;
padding-left: 14px;
}
.docs-chat-assistant li::before {
content: "•";
position: absolute;
left: 0;
color: var(--docs-chat-muted);
}
.docs-chat-assistant strong { font-weight: 600; }
.docs-chat-assistant em { font-style: italic; }
.docs-chat-assistant h1, .docs-chat-assistant h2, .docs-chat-assistant h3 {
font-weight: 600;
margin: 12px 0 6px 0;
line-height: 1.3;
}
.docs-chat-assistant h1 { font-size: 1.2em; }
.docs-chat-assistant h2 { font-size: 1.1em; }
.docs-chat-assistant h3 { font-size: 1.05em; }
.docs-chat-assistant blockquote {
border-left: 3px solid var(--docs-chat-accent);
margin: 10px 0;
padding: 4px 12px;
color: var(--docs-chat-muted);
background: var(--docs-chat-code-bg);
border-radius: 0 6px 6px 0;
}
.docs-chat-assistant hr {
border: none;
height: 1px;
background: var(--docs-chat-panel-border);
margin: 12px 0;
}
/* Copy buttons */
.docs-chat-assistant { position: relative; padding-top: 28px; }
.docs-chat-copy-response {
position: absolute;
top: 8px;
right: 8px;
background: var(--docs-chat-surface);
border: 1px solid var(--docs-chat-panel-border);
border-radius: 5px;
padding: 4px 8px;
font-size: 11px;
cursor: pointer;
color: var(--docs-chat-muted);
transition: color 0.15s ease, background 0.15s ease;
}
.docs-chat-copy-response:hover {
color: var(--docs-chat-text);
background: var(--docs-chat-code-bg);
}
.docs-chat-assistant pre {
position: relative;
}
.docs-chat-copy-code {
position: absolute;
top: 8px;
right: 8px;
background: var(--docs-chat-surface);
border: 1px solid var(--docs-chat-panel-border);
border-radius: 4px;
padding: 3px 7px;
font-size: 10px;
cursor: pointer;
color: var(--docs-chat-muted);
transition: color 0.15s ease, background 0.15s ease;
z-index: 1;
}
.docs-chat-copy-code:hover {
color: var(--docs-chat-text);
background: var(--docs-chat-code-bg);
}
/* Resize handle - left edge of expanded panel */
#docs-chat-resize-handle {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6px;
cursor: ew-resize;
z-index: 10;
display: none;
}
#docs-chat-root.docs-chat-expanded #docs-chat-resize-handle { display: block; }
#docs-chat-resize-handle::after {
content: "";
position: absolute;
left: 1px;
top: 50%;
transform: translateY(-50%);
width: 4px;
height: 40px;
border-radius: 2px;
background: var(--docs-chat-panel-border);
opacity: 0;
transition: opacity 0.15s ease, background 0.15s ease;
}
#docs-chat-resize-handle:hover::after,
#docs-chat-resize-handle.docs-chat-dragging::after {
opacity: 1;
background: var(--docs-chat-accent);
}
@media (max-width: 520px) {
#docs-chat-resize-handle { display: none !important; }
}
`;
document.head.appendChild(style);
const root = document.createElement("div");
root.id = "docs-chat-root";
const button = document.createElement("button");
button.id = "docs-chat-button";
button.type = "button";
button.innerHTML =
`<img class="docs-chat-logo" src="${assetsBase}/assets/pixel-lobster.svg" alt="OpenClaw">` +
`<span>Ask Molty</span>`;
const panel = document.createElement("div");
panel.id = "docs-chat-panel";
panel.style.display = "none";
// Resize handle for expandable sidebar width (desktop only)
const resizeHandle = document.createElement("div");
resizeHandle.id = "docs-chat-resize-handle";
const header = document.createElement("div");
header.id = "docs-chat-header";
header.innerHTML =
`<div id="docs-chat-header-title">` +
`<img class="docs-chat-logo" src="${assetsBase}/assets/pixel-lobster.svg" alt="OpenClaw">` +
`<span>OpenClaw Docs</span>` +
`</div>` +
`<div id="docs-chat-header-actions"></div>`;
const headerActions = header.querySelector("#docs-chat-header-actions");
const expand = document.createElement("button");
expand.type = "button";
expand.className = "docs-chat-icon-button";
expand.setAttribute("aria-label", "Expand");
expand.textContent = "⤢";
const clear = document.createElement("button");
clear.type = "button";
clear.className = "docs-chat-icon-button";
clear.setAttribute("aria-label", "Clear chat");
clear.textContent = "⌫";
const close = document.createElement("button");
close.type = "button";
close.className = "docs-chat-icon-button";
close.setAttribute("aria-label", "Close");
close.textContent = "×";
headerActions.appendChild(expand);
headerActions.appendChild(clear);
headerActions.appendChild(close);
const messages = document.createElement("div");
messages.id = "docs-chat-messages";
const inputWrap = document.createElement("div");
inputWrap.id = "docs-chat-input";
const textarea = document.createElement("textarea");
textarea.rows = 1;
textarea.placeholder = "Ask about OpenClaw Docs...";
// Auto-expand textarea as user types (up to max-height set in CSS)
const autoExpand = () => {
textarea.style.height = "auto";
textarea.style.height = Math.min(textarea.scrollHeight, 224) + "px";
};
textarea.addEventListener("input", autoExpand);
const send = document.createElement("button");
send.id = "docs-chat-send";
send.type = "button";
send.textContent = "Send";
inputWrap.appendChild(textarea);
inputWrap.appendChild(send);
panel.appendChild(resizeHandle);
panel.appendChild(header);
panel.appendChild(messages);
panel.appendChild(inputWrap);
root.appendChild(button);
root.appendChild(panel);
document.body.appendChild(root);
// Add copy buttons to assistant bubble
const addCopyButtons = (bubble, rawText) => {
// Add copy response button
const copyResponse = document.createElement("button");
copyResponse.className = "docs-chat-copy-response";
copyResponse.textContent = "Copy";
copyResponse.type = "button";
copyResponse.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(rawText);
copyResponse.textContent = "Copied!";
setTimeout(() => (copyResponse.textContent = "Copy"), 1500);
} catch (e) {
copyResponse.textContent = "Failed";
}
});
bubble.appendChild(copyResponse);
// Add copy buttons to code blocks (skip short/single-line blocks)
bubble.querySelectorAll("pre").forEach((pre) => {
const code = pre.querySelector("code") || pre;
const text = code.textContent || "";
const lineCount = text.split("\n").length;
const isShort = lineCount <= 2 && text.length < 100;
if (isShort) {
pre.classList.add("compact");
return; // Skip copy button for compact blocks
}
const copyCode = document.createElement("button");
copyCode.className = "docs-chat-copy-code";
copyCode.textContent = "Copy";
copyCode.type = "button";
copyCode.addEventListener("click", async (e) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(text);
copyCode.textContent = "Copied!";
setTimeout(() => (copyCode.textContent = "Copy"), 1500);
} catch (err) {
copyCode.textContent = "Failed";
}
});
pre.appendChild(copyCode);
});
};
const addBubble = (text, role, isMarkdown = false) => {
const bubble = document.createElement("div");
bubble.className =
"docs-chat-bubble " +
(role === "user" ? "docs-chat-user" : "docs-chat-assistant");
if (isMarkdown && role === "assistant") {
bubble.innerHTML = renderMarkdown(text);
} else {
bubble.textContent = text;
}
messages.appendChild(bubble);
messages.scrollTop = messages.scrollHeight;
return bubble;
};
let isExpanded = false;
let customWidth = null; // User-set width via drag
const MIN_WIDTH = 320;
const MAX_WIDTH = 800;
// Drag-to-resize logic
let isDragging = false;
let startX, startWidth;
resizeHandle.addEventListener("mousedown", (e) => {
if (!isExpanded) return;
isDragging = true;
startX = e.clientX;
startWidth = panel.offsetWidth;
resizeHandle.classList.add("docs-chat-dragging");
document.body.style.cursor = "ew-resize";
document.body.style.userSelect = "none";
e.preventDefault();
});
document.addEventListener("mousemove", (e) => {
if (!isDragging) return;
// Panel is on right, so dragging left increases width
const delta = startX - e.clientX;
const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + delta));
customWidth = newWidth;
panel.style.width = newWidth + "px";
});
document.addEventListener("mouseup", () => {
if (!isDragging) return;
isDragging = false;
resizeHandle.classList.remove("docs-chat-dragging");
document.body.style.cursor = "";
document.body.style.userSelect = "";
});
const setOpen = (isOpen) => {
panel.style.display = isOpen ? "flex" : "none";
button.style.display = isOpen ? "none" : "inline-flex";
root.classList.toggle("docs-chat-expanded", isOpen && isExpanded);
if (!isOpen) {
panel.style.width = ""; // Reset to CSS default when closed
} else if (isExpanded && customWidth) {
panel.style.width = customWidth + "px";
}
if (isOpen) textarea.focus();
};
const setExpanded = (next) => {
isExpanded = next;
expand.textContent = isExpanded ? "⤡" : "⤢";
expand.setAttribute("aria-label", isExpanded ? "Collapse" : "Expand");
if (panel.style.display !== "none") {
root.classList.toggle("docs-chat-expanded", isExpanded);
if (isExpanded && customWidth) {
panel.style.width = customWidth + "px";
} else if (!isExpanded) {
panel.style.width = ""; // Reset to CSS default
}
}
};
button.addEventListener("click", () => setOpen(true));
expand.addEventListener("click", () => setExpanded(!isExpanded));
clear.addEventListener("click", () => {
messages.innerHTML = "";
});
close.addEventListener("click", () => {
setOpen(false);
root.classList.remove("docs-chat-expanded");
});
const sendMessage = async () => {
const text = textarea.value.trim();
if (!text) return;
textarea.value = "";
textarea.style.height = "auto"; // Reset height after sending
addBubble(text, "user");
const assistantBubble = addBubble("...", "assistant");
assistantBubble.innerHTML = "";
let fullText = "";
try {
const response = await fetch(`${apiBase}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text }),
});
// Handle rate limiting
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After") || "60";
fullText = `You're asking questions too quickly. Please wait ${retryAfter} seconds before trying again.`;
assistantBubble.innerHTML = renderMarkdown(fullText);
addCopyButtons(assistantBubble, fullText);
return;
}
// Handle other errors
if (!response.ok) {
try {
const errorData = await response.json();
fullText = errorData.error || "Something went wrong. Please try again.";
} catch {
fullText = "Something went wrong. Please try again.";
}
assistantBubble.innerHTML = renderMarkdown(fullText);
addCopyButtons(assistantBubble, fullText);
return;
}
if (!response.body) {
fullText = await response.text();
assistantBubble.innerHTML = renderMarkdown(fullText);
addCopyButtons(assistantBubble, fullText);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
fullText += decoder.decode(value, { stream: true });
// Re-render markdown on each chunk for live preview
assistantBubble.innerHTML = renderMarkdown(fullText);
messages.scrollTop = messages.scrollHeight;
}
// Flush any remaining buffered bytes (partial UTF-8 sequences)
fullText += decoder.decode();
assistantBubble.innerHTML = renderMarkdown(fullText);
// Add copy buttons after streaming completes
addCopyButtons(assistantBubble, fullText);
} catch (err) {
fullText = "Failed to reach docs chat API.";
assistantBubble.innerHTML = renderMarkdown(fullText);
addCopyButtons(assistantBubble, fullText);
}
};
send.addEventListener("click", sendMessage);
textarea.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
});
})();

View File

@@ -23,7 +23,7 @@ cron is the mechanism.
- Jobs persist under `~/.openclaw/cron/` so restarts dont lose schedules.
- Two execution styles:
- **Main session**: enqueue a system event, then run on the next heartbeat.
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, optionally deliver output.
- **Isolated**: run a dedicated agent turn in `cron:<jobId>`, with delivery (announce by default or none).
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
## Quick start (actionable)
@@ -53,7 +53,7 @@ openclaw cron add \
--tz "America/Los_Angeles" \
--session isolated \
--message "Summarize overnight updates." \
--deliver \
--announce \
--channel slack \
--to "channel:C1234567890"
```
@@ -86,7 +86,8 @@ Think of a cron job as: **when** to run + **what** to do.
- Main session → `payload.kind = "systemEvent"`
- Isolated session → `payload.kind = "agentTurn"`
Optional: `deleteAfterRun: true` removes successful one-shot jobs from the store.
Optional: one-shot jobs (`schedule.kind = "at"`) delete after success by default. Set
`deleteAfterRun: false` to keep them (they will disable after success).
## Concepts
@@ -96,19 +97,19 @@ A cron job is a stored record with:
- a **schedule** (when it should run),
- a **payload** (what it should do),
- optional **delivery** (where output should be sent).
- optional **delivery mode** (announce or none).
- optional **agent binding** (`agentId`): run the job under a specific agent; if
missing or unknown, the gateway falls back to the default agent.
Jobs are identified by a stable `jobId` (used by CLI/Gateway APIs).
In agent tool calls, `jobId` is canonical; legacy `id` is accepted for compatibility.
Jobs can optionally auto-delete after a successful one-shot run via `deleteAfterRun: true`.
One-shot jobs auto-delete after success by default; set `deleteAfterRun: false` to keep them.
### Schedules
Cron supports three schedule kinds:
- `at`: one-shot timestamp (ms since epoch). Gateway accepts ISO 8601 and coerces to UTC.
- `at`: one-shot timestamp via `schedule.at` (ISO 8601).
- `every`: fixed interval (ms).
- `cron`: 5-field cron expression with optional IANA timezone.
@@ -136,9 +137,13 @@ Key behaviors:
- Prompt is prefixed with `[cron:<jobId> <job name>]` for traceability.
- Each run starts a **fresh session id** (no prior conversation carry-over).
- A summary is posted to the main session (prefix `Cron`, configurable).
- `wakeMode: "now"` triggers an immediate heartbeat after posting the summary.
- If `payload.deliver: true`, output is delivered to a channel; otherwise it stays internal.
- Default behavior: if `delivery` is omitted, isolated jobs announce a summary (`delivery.mode = "announce"`).
- `delivery.mode` (isolated-only) chooses what happens:
- `announce`: deliver a summary to the target channel and post a brief summary to the main session.
- `none`: internal only (no delivery, no main-session summary).
- `wakeMode` controls when the main-session summary posts:
- `now`: immediate heartbeat.
- `next-heartbeat`: waits for the next scheduled heartbeat.
Use isolated jobs for noisy, frequent, or "background chores" that shouldn't spam
your main chat history.
@@ -155,16 +160,35 @@ Common `agentTurn` fields:
- `message`: required text prompt.
- `model` / `thinking`: optional overrides (see below).
- `timeoutSeconds`: optional timeout override.
- `deliver`: `true` to send output to a channel target.
- `channel`: `last` or a specific channel.
- `to`: channel-specific target (phone/chat/channel id).
- `bestEffortDeliver`: avoid failing the job if delivery fails.
Isolation options (only for `session=isolated`):
Delivery config (isolated jobs only):
- `postToMainPrefix` (CLI: `--post-prefix`): prefix for the system event in main.
- `postToMainMode`: `summary` (default) or `full`.
- `postToMainMaxChars`: max chars when `postToMainMode=full` (default 8000).
- `delivery.mode`: `none` | `announce`.
- `delivery.channel`: `last` or a specific channel.
- `delivery.to`: channel-specific target (phone/chat/channel id).
- `delivery.bestEffort`: avoid failing the job if announce delivery fails.
Announce delivery suppresses messaging tool sends for the run; use `delivery.channel`/`delivery.to`
to target the chat instead. When `delivery.mode = "none"`, no summary is posted to the main session.
If `delivery` is omitted for isolated jobs, OpenClaw defaults to `announce`.
#### Announce delivery flow
When `delivery.mode = "announce"`, cron delivers directly via the outbound channel adapters.
The main agent is not spun up to craft or forward the message.
Behavior details:
- Content: delivery uses the isolated run's outbound payloads (text/media) with normal chunking and
channel formatting.
- Heartbeat-only responses (`HEARTBEAT_OK` with no real content) are not delivered.
- If the isolated run already sent a message to the same target via the message tool, delivery is
skipped to avoid duplicates.
- Missing or invalid delivery targets fail the job unless `delivery.bestEffort = true`.
- A short summary is posted to the main session only when `delivery.mode = "announce"`.
- The main-session summary respects `wakeMode`: `now` triggers an immediate heartbeat and
`next-heartbeat` waits for the next scheduled heartbeat.
### Model and thinking overrides
@@ -185,19 +209,16 @@ Resolution priority:
### Delivery (channel + target)
Isolated jobs can deliver output to a channel. The job payload can specify:
Isolated jobs can deliver output to a channel via the top-level `delivery` config:
- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`
- `to`: channel-specific recipient target
- `delivery.mode`: `announce` (deliver a summary) or `none`.
- `delivery.channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `signal` / `imessage` / `last`.
- `delivery.to`: channel-specific recipient target.
If `channel` or `to` is omitted, cron can fall back to the main sessions “last route”
(the last place the agent replied).
Delivery config is only valid for isolated jobs (`sessionTarget: "isolated"`).
Delivery notes:
- If `to` is set, cron auto-delivers the agents final output even if `deliver` is omitted.
- Use `deliver: true` when you want last-route delivery without an explicit `to`.
- Use `deliver: false` to keep output internal even if a `to` is present.
If `delivery.channel` or `delivery.to` is omitted, cron can fall back to the main sessions
“last route” (the last place the agent replied).
Target format reminders:
@@ -220,8 +241,8 @@ Prefixed targets like `telegram:...` / `telegram:group:...` are also accepted:
## JSON schema for tool calls
Use these shapes when calling Gateway `cron.*` tools directly (agent tool calls or RPC).
CLI flags accept human durations like `20m`, but tool calls use epoch milliseconds for
`atMs` and `everyMs` (ISO timestamps are accepted for `at` times).
CLI flags accept human durations like `20m`, but tool calls should use an ISO 8601 string
for `schedule.at` and milliseconds for `schedule.everyMs`.
### cron.add params
@@ -230,7 +251,7 @@ One-shot, main session job (system event):
```json
{
"name": "Reminder",
"schedule": { "kind": "at", "atMs": 1738262400000 },
"schedule": { "kind": "at", "at": "2026-02-01T16:00:00Z" },
"sessionTarget": "main",
"wakeMode": "now",
"payload": { "kind": "systemEvent", "text": "Reminder text" },
@@ -248,22 +269,25 @@ Recurring, isolated job with delivery:
"wakeMode": "next-heartbeat",
"payload": {
"kind": "agentTurn",
"message": "Summarize overnight updates.",
"deliver": true,
"message": "Summarize overnight updates."
},
"delivery": {
"mode": "announce",
"channel": "slack",
"to": "channel:C1234567890",
"bestEffortDeliver": true
},
"isolation": { "postToMainPrefix": "Cron", "postToMainMode": "summary" }
"bestEffort": true
}
}
```
Notes:
- `schedule.kind`: `at` (`atMs`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`).
- `atMs` and `everyMs` are epoch milliseconds.
- `schedule.kind`: `at` (`at`), `every` (`everyMs`), or `cron` (`expr`, optional `tz`).
- `schedule.at` accepts ISO 8601 (timezone optional; treated as UTC when omitted).
- `everyMs` is milliseconds.
- `sessionTarget` must be `"main"` or `"isolated"` and must match `payload.kind`.
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun`, `isolation`.
- Optional fields: `agentId`, `description`, `enabled`, `deleteAfterRun` (defaults to true for `at`),
`delivery`.
- `wakeMode` defaults to `"next-heartbeat"` when omitted.
### cron.update params
@@ -341,7 +365,7 @@ openclaw cron add \
--wake now
```
Recurring isolated job (deliver to WhatsApp):
Recurring isolated job (announce to WhatsApp):
```bash
openclaw cron add \
@@ -350,7 +374,7 @@ openclaw cron add \
--tz "America/Los_Angeles" \
--session isolated \
--message "Summarize inbox + calendar for today." \
--deliver \
--announce \
--channel whatsapp \
--to "+15551234567"
```
@@ -364,7 +388,7 @@ openclaw cron add \
--tz "America/Los_Angeles" \
--session isolated \
--message "Summarize today; send to the nightly topic." \
--deliver \
--announce \
--channel telegram \
--to "-1001234567890:topic:123"
```
@@ -380,7 +404,7 @@ openclaw cron add \
--message "Weekly deep analysis of project progress." \
--model "opus" \
--thinking high \
--deliver \
--announce \
--channel whatsapp \
--to "+15551234567"
```

View File

@@ -90,7 +90,8 @@ Cron jobs run at **exact times** and can run in isolated sessions without affect
- **Exact timing**: 5-field cron expressions with timezone support.
- **Session isolation**: Runs in `cron:<jobId>` without polluting main history.
- **Model overrides**: Use a cheaper or more powerful model per job.
- **Delivery control**: Can deliver directly to a channel; still posts a summary to main by default (configurable).
- **Delivery control**: Isolated jobs default to `announce` (summary); choose `none` as needed.
- **Immediate delivery**: Announce mode posts directly without waiting for heartbeat.
- **No agent context needed**: Runs even if main session is idle or compacted.
- **One-shot support**: `--at` for precise future timestamps.
@@ -104,12 +105,12 @@ openclaw cron add \
--session isolated \
--message "Generate today's briefing: weather, calendar, top emails, news summary." \
--model opus \
--deliver \
--announce \
--channel whatsapp \
--to "+15551234567"
```
This runs at exactly 7:00 AM New York time, uses Opus for quality, and delivers directly to WhatsApp.
This runs at exactly 7:00 AM New York time, uses Opus for quality, and announces a summary directly to WhatsApp.
### Cron example: One-shot reminder
@@ -173,7 +174,7 @@ The most efficient setup uses **both**:
```bash
# Daily morning briefing at 7am
openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --deliver
openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --announce
# Weekly project review on Mondays at 9am
openclaw cron add --name "Weekly review" --cron "0 9 * * 1" --session isolated --message "..." --model opus
@@ -214,13 +215,13 @@ See [Lobster](/tools/lobster) for full usage and examples.
Both heartbeat and cron can interact with the main session, but differently:
| | Heartbeat | Cron (main) | Cron (isolated) |
| ------- | ------------------------------- | ------------------------ | ---------------------- |
| Session | Main | Main (via system event) | `cron:<jobId>` |
| History | Shared | Shared | Fresh each run |
| Context | Full | Full | None (starts clean) |
| Model | Main session model | Main session model | Can override |
| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Summary posted to main |
| | Heartbeat | Cron (main) | Cron (isolated) |
| ------- | ------------------------------- | ------------------------ | -------------------------- |
| Session | Main | Main (via system event) | `cron:<jobId>` |
| History | Shared | Shared | Fresh each run |
| Context | Full | Full | None (starts clean) |
| Model | Main session model | Main session model | Can override |
| Output | Delivered if not `HEARTBEAT_OK` | Heartbeat prompt + event | Announce summary (default) |
### When to use main session cron
@@ -245,7 +246,7 @@ Use `--session isolated` when you want:
- A clean slate without prior context
- Different model or thinking settings
- Output delivered directly to a channel (summary still posts to main by default)
- Announce summaries directly to a channel
- History that doesn't clutter main session
```bash
@@ -256,7 +257,7 @@ openclaw cron add \
--message "Weekly codebase analysis..." \
--model opus \
--thinking high \
--deliver
--announce
```
## Cost Considerations

View File

@@ -42,6 +42,80 @@ Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **R
4. Point BlueBubbles webhooks to your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=<password>`).
5. Start the gateway; it will register the webhook handler and start pairing.
## Keeping Messages.app alive (VM / headless setups)
Some macOS VM / always-on setups can end up with Messages.app going “idle” (incoming events stop until the app is opened/foregrounded). A simple workaround is to **poke Messages every 5 minutes** using an AppleScript + LaunchAgent.
### 1) Save the AppleScript
Save this as:
- `~/Scripts/poke-messages.scpt`
Example script (non-interactive; does not steal focus):
```applescript
try
tell application "Messages"
if not running then
launch
end if
-- Touch the scripting interface to keep the process responsive.
set _chatCount to (count of chats)
end tell
on error
-- Ignore transient failures (first-run prompts, locked session, etc).
end try
```
### 2) Install a LaunchAgent
Save this as:
- `~/Library/LaunchAgents/com.user.poke-messages.plist`
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.user.poke-messages</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-lc</string>
<string>/usr/bin/osascript &quot;$HOME/Scripts/poke-messages.scpt&quot;</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>300</integer>
<key>StandardOutPath</key>
<string>/tmp/poke-messages.log</string>
<key>StandardErrorPath</key>
<string>/tmp/poke-messages.err</string>
</dict>
</plist>
```
Notes:
- This runs **every 300 seconds** and **on login**.
- The first run may trigger macOS **Automation** prompts (`osascript` → Messages). Approve them in the same user session that runs the LaunchAgent.
Load it:
```bash
launchctl unload ~/Library/LaunchAgents/com.user.poke-messages.plist 2>/dev/null || true
launchctl load ~/Library/LaunchAgents/com.user.poke-messages.plist
```
## Onboarding
BlueBubbles is available in the interactive setup wizard:

View File

@@ -100,7 +100,7 @@ In **Bot** → **Privileged Gateway Intents**, enable:
- **Message Content Intent** (required to read message text in most guilds; without it youll see “Used disallowed intents” or the bot will connect but not react to messages)
- **Server Members Intent** (recommended; required for some member/user lookups and allowlist matching in guilds)
You usually do **not** need **Presence Intent**.
You usually do **not** need **Presence Intent**. Setting the bot's own presence (`setPresence` action) uses gateway OP3 and does not require this intent; it is only needed if you want to receive presence updates about other guild members.
### 3) Generate an invite URL (OAuth2 URL Generator)
@@ -278,6 +278,7 @@ Outbound Discord API calls retry on rate limits (429) using Discord `retry_after
voiceStatus: true,
events: true,
moderation: false,
presence: false,
},
replyToMode: "off",
dm: {
@@ -353,6 +354,7 @@ ack reaction after the bot replies.
- `channels` (create/edit/delete channels + categories + permissions)
- `roles` (role add/remove, default `false`)
- `moderation` (timeout/kick/ban, default `false`)
- `presence` (bot status/activity, default `false`)
- `execApprovals`: Discord-only exec approval DMs (button UI). Supports `enabled`, `approvers`, `agentFilter`, `sessionFilter`.
Reaction notifications use `guilds.<id>.reactionNotifications`:
@@ -412,6 +414,7 @@ Allowlist notes (PK-enabled):
| events | enabled | List/create scheduled events |
| roles | disabled | Role add/remove |
| moderation | disabled | Timeout/kick/ban |
| presence | disabled | Bot status/activity (setPresence) |
- `replyToMode`: `off` (default), `first`, or `all`. Applies only when the model includes a reply tag.
@@ -460,6 +463,7 @@ The agent can call `discord` with actions like:
- `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList`
- `channelInfo`, `channelList`, `voiceStatus`, `eventList`, `eventCreate`
- `timeout`, `kick`, `ban`
- `setPresence` (bot activity and online status)
Discord message ids are surfaced in the injected context (`[discord message id: …]` and history lines) so the agent can target them.
Emoji can be unicode (e.g., `✅`) or custom emoji syntax like `<:party_blob:1234567890>`.

View File

@@ -1,5 +1,5 @@
---
summary: "Feishu bot support status, features, and configuration"
summary: "Feishu bot overview, features, and configuration"
read_when:
- You want to connect a Feishu/Lark bot
- You are configuring the Feishu channel
@@ -8,7 +8,7 @@ title: Feishu
# Feishu bot
Status: production-ready, supports bot DMs and group chats. Uses WebSocket long connection mode to receive events.
Feishu (Lark) is a team chat platform used by companies for messaging and collaboration. This plugin connects OpenClaw to a Feishu/Lark bot using the platforms WebSocket event subscription so messages can be received without exposing a public webhook URL.
---

View File

@@ -1,14 +1,18 @@
---
summary: "iMessage support via imsg (JSON-RPC over stdio), setup, and chat_id routing"
summary: "Legacy iMessage support via imsg (JSON-RPC over stdio). New setups should use BlueBubbles."
read_when:
- Setting up iMessage support
- Debugging iMessage send/receive
title: iMessage
---
# iMessage (imsg)
# iMessage (legacy: imsg)
Status: external CLI integration. Gateway spawns `imsg rpc` (JSON-RPC over stdio).
> **Recommended:** Use [BlueBubbles](/channels/bluebubbles) for new iMessage setups.
>
> The `imsg` channel is a legacy external-CLI integration and may be removed in a future release.
Status: legacy external CLI integration. Gateway spawns `imsg rpc` (JSON-RPC over stdio).
## Quick setup (beginner)

View File

@@ -22,7 +22,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately).
- [Signal](/channels/signal) — signal-cli; privacy-focused.
- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe).
- [iMessage](/channels/imessage) — macOS only; native integration via imsg (legacy, consider BlueBubbles for new setups).
- [iMessage (legacy)](/channels/imessage) — Legacy macOS integration via imsg CLI (deprecated, use BlueBubbles for new setups).
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
- [LINE](/channels/line) — LINE Messaging API bot (plugin, installed separately).
- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately).

View File

@@ -72,6 +72,7 @@ Minimal config:
- `openclaw pairing list nextcloud-talk`
- `openclaw pairing approve nextcloud-talk <CODE>`
- Public DMs: `channels.nextcloud-talk.dmPolicy="open"` plus `channels.nextcloud-talk.allowFrom=["*"]`.
- `allowFrom` matches Nextcloud user IDs only; display names are ignored.
## Rooms (groups)

View File

@@ -16,12 +16,17 @@ Related:
Tip: run `openclaw cron --help` for the full command surface.
Note: isolated `cron add` jobs default to `--announce` delivery. Use `--no-deliver` to keep
output internal. `--deliver` remains as a deprecated alias for `--announce`.
Note: one-shot (`--at`) jobs delete after success by default. Use `--keep-after-run` to keep them.
## Common edits
Update delivery settings without changing the message:
```bash
openclaw cron edit <job-id> --deliver --channel telegram --to "123456789"
openclaw cron edit <job-id> --announce --channel telegram --to "123456789"
```
Disable delivery for an isolated job:
@@ -29,3 +34,9 @@ Disable delivery for an isolated job:
```bash
openclaw cron edit <job-id> --no-deliver
```
Announce to a specific channel:
```bash
openclaw cron edit <job-id> --announce --channel slack --to "channel:C1234567890"
```

View File

@@ -303,7 +303,7 @@ Options:
- `--non-interactive`
- `--mode <local|remote>`
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip>`
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip>`
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
- `--token <token>` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)

View File

@@ -22,5 +22,5 @@ openclaw security audit --deep
openclaw security audit --fix
```
The audit warns when multiple DM senders share the main session and recommends `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes.
The audit warns when multiple DM senders share the main session and recommends **secure DM mode**: `session.dmScope="per-channel-peer"` (or `per-account-channel-peer` for multi-account channels) for shared inboxes.
It also warns when small models (`<=300B`) are used without sandboxing and with web/browser tools enabled.

View File

@@ -17,6 +17,26 @@ Use `session.dmScope` to control how **direct messages** are grouped:
- `per-account-channel-peer`: isolate by account + channel + sender (recommended for multi-account inboxes).
Use `session.identityLinks` to map provider-prefixed peer ids to a canonical identity so the same person shares a DM session across channels when using `per-peer`, `per-channel-peer`, or `per-account-channel-peer`.
### Secure DM mode (recommended)
If your agent can receive DMs from **multiple people** (pairing approvals for more than one sender, a DM allowlist with multiple entries, or `dmPolicy: "open"`), enable **secure DM mode** to avoid cross-user context leakage:
```json5
// ~/.openclaw/openclaw.json
{
session: {
// Secure DM mode: isolate DM context per channel + sender.
dmScope: "per-channel-peer",
},
}
```
Notes:
- Default is `dmScope: "main"` for continuity (all DMs share the main session).
- For multi-account inboxes on the same channel, prefer `per-account-channel-peer`.
- If the same person contacts you on multiple channels, use `session.identityLinks` to collapse their DM sessions into one canonical identity.
## Gateway is the source of truth
All session state is **owned by the gateway** (the “master” OpenClaw). UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files.

View File

@@ -446,6 +446,32 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
}
```
### Secure DM mode (shared inbox / multi-user DMs)
If more than one person can DM your bot (multiple entries in `allowFrom`, pairing approvals for multiple people, or `dmPolicy: "open"`), enable **secure DM mode** so DMs from different senders dont share one context by default:
```json5
{
// Secure DM mode (recommended for multi-user or sensitive DM agents)
session: { dmScope: "per-channel-peer" },
channels: {
// Example: WhatsApp multi-user inbox
whatsapp: {
dmPolicy: "allowlist",
allowFrom: ["+15555550123", "+15555550124"],
},
// Example: Discord multi-user inbox
discord: {
enabled: true,
token: "YOUR_DISCORD_BOT_TOKEN",
dm: { enabled: true, allowFrom: ["alice", "bob"] },
},
},
}
```
### OAuth with API key failover
```json5

View File

@@ -2039,6 +2039,7 @@ of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
- `tools.web.search.cacheTtlMinutes` (default 15)
- `tools.web.fetch.enabled` (default true)
- `tools.web.fetch.maxChars` (default 50000)
- `tools.web.fetch.maxCharsCap` (default 50000; clamps maxChars from config/tool calls)
- `tools.web.fetch.timeoutSeconds` (default 30)
- `tools.web.fetch.cacheTtlMinutes` (default 15)
- `tools.web.fetch.userAgent` (optional override)
@@ -2551,7 +2552,9 @@ Notes:
- Set `MOONSHOT_API_KEY` in the environment or use `openclaw onboard --auth-choice moonshot-api-key`.
- Model ref: `moonshot/kimi-k2.5`.
- Use `https://api.moonshot.cn/v1` if you need the China endpoint.
- For the China endpoint, either:
- Run `openclaw onboard --auth-choice moonshot-api-key-cn` (wizard will set `https://api.moonshot.cn/v1`), or
- Manually set `baseUrl: "https://api.moonshot.cn/v1"` in `models.providers.moonshot`.
### Kimi Coding
@@ -2763,6 +2766,7 @@ Fields:
- `per-peer`: isolate DMs by sender id across channels.
- `per-channel-peer`: isolate DMs per channel + sender (recommended for multi-user inboxes).
- `per-account-channel-peer`: isolate DMs per account + channel + sender (recommended for multi-account inboxes).
- Secure DM mode (recommended): set `session.dmScope: "per-channel-peer"` when multiple people can DM the bot (shared inboxes, multi-person allowlists, or `dmPolicy: "open"`).
- `identityLinks`: map canonical ids to provider-prefixed peers so the same person shares a DM session across channels when using `per-peer`, `per-channel-peer`, or `per-account-channel-peer`.
- Example: `alice: ["telegram:123456789", "discord:987654321012345678"]`.
- `reset`: primary reset policy. Defaults to daily resets at 4:00 AM local time on the gateway host.

View File

@@ -205,7 +205,16 @@ By default, OpenClaw routes **all DMs into the main session** so your assistant
}
```
This prevents cross-user context leakage while keeping group chats isolated. If you run multiple accounts on the same channel, use `per-account-channel-peer` instead. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
This prevents cross-user context leakage while keeping group chats isolated.
### Secure DM mode (recommended)
Treat the snippet above as **secure DM mode**:
- Default: `session.dmScope: "main"` (all DMs share one session for continuity).
- Secure DM mode: `session.dmScope: "per-channel-peer"` (each channel+sender pair gets an isolated DM context).
If you run multiple accounts on the same channel, use `per-account-channel-peer` instead. If the same person contacts you on multiple channels, use `session.identityLinks` to collapse those DM sessions into one canonical identity. See [Session Management](/concepts/session) and [Configuration](/gateway/configuration).
## Allowlists (DM + groups) — terminology

File diff suppressed because it is too large Load Diff

View File

@@ -210,7 +210,8 @@ Example:
- [Telegram](/channels/telegram)
- [Discord](/channels/discord)
- [Mattermost (plugin)](/channels/mattermost)
- [iMessage](/channels/imessage)
- [BlueBubbles (iMessage)](/channels/bluebubbles)
- [iMessage (legacy)](/channels/imessage)
- [Groups](/concepts/groups)
- [WhatsApp group messages](/concepts/group-messages)
- [Media: images](/nodes/images)

View File

@@ -446,7 +446,10 @@ Example voice-call config with ngrok:
"enabled": true,
"config": {
"provider": "twilio",
"tunnel": { "provider": "ngrok" }
"tunnel": { "provider": "ngrok" },
"webhookSecurity": {
"allowedHosts": ["example.ngrok.app"]
}
}
}
}
@@ -454,7 +457,7 @@ Example voice-call config with ngrok:
}
```
The ngrok tunnel runs inside the container and provides a public webhook URL without exposing the Fly app itself.
The ngrok tunnel runs inside the container and provides a public webhook URL without exposing the Fly app itself. Set `webhookSecurity.allowedHosts` to the public tunnel hostname so forwarded host headers are accepted.
### Security benefits

View File

@@ -34,17 +34,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=bot.molt.mac \
APP_VERSION=2026.2.2 \
APP_VERSION=2026.2.3 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support)
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.2.zip
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.3.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.2.dmg
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.3.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@@ -52,14 +52,14 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.2.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
BUNDLE_ID=bot.molt.mac \
APP_VERSION=2026.2.2 \
APP_VERSION=2026.2.3 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.2.dSYM.zip
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.3.dSYM.zip
```
## Appcast entry
@@ -67,7 +67,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.2.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.3.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
@@ -75,7 +75,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when
## Publish & verify
- Upload `OpenClaw-2026.2.2.zip` (and `OpenClaw-2026.2.2.dSYM.zip`) to the GitHub release for tag `v2026.2.2`.
- Upload `OpenClaw-2026.2.3.zip` (and `OpenClaw-2026.2.3.dSYM.zip`) to the GitHub release for tag `v2026.2.3`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.

View File

@@ -81,6 +81,12 @@ Set config under `plugins.entries.voice-call.config`:
path: "/voice/webhook",
},
// Webhook security (recommended for tunnels/proxies)
webhookSecurity: {
allowedHosts: ["voice.example.com"],
trustedProxyIPs: ["100.64.0.1"],
},
// Public exposure (pick one)
// publicUrl: "https://example.ngrok.app/voice/webhook",
// tunnel: { provider: "ngrok" },
@@ -111,6 +117,38 @@ Notes:
- `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only.
- Ngrok free tier URLs can change or add interstitial behavior; if `publicUrl` drifts, Twilio signatures will fail. For production, prefer a stable domain or Tailscale funnel.
## Webhook Security
When a proxy or tunnel sits in front of the Gateway, the plugin reconstructs the
public URL for signature verification. These options control which forwarded
headers are trusted.
`webhookSecurity.allowedHosts` allowlists hosts from forwarding headers.
`webhookSecurity.trustForwardingHeaders` trusts forwarded headers without an allowlist.
`webhookSecurity.trustedProxyIPs` only trusts forwarded headers when the request
remote IP matches the list.
Example with a stable public host:
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
publicUrl: "https://voice.example.com/voice/webhook",
webhookSecurity: {
allowedHosts: ["voice.example.com"],
},
},
},
},
},
}
```
## TTS for calls
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for

View File

@@ -106,6 +106,7 @@ Current npm plugin list (update as needed):
- @openclaw/bluebubbles
- @openclaw/diagnostics-otel
- @openclaw/discord
- @openclaw/feishu
- @openclaw/lobster
- @openclaw/matrix
- @openclaw/msteams

View File

@@ -1,5 +1,5 @@
---
summary: "RPC adapters for external CLIs (signal-cli, imsg) and gateway patterns"
summary: "RPC adapters for external CLIs (signal-cli, legacy imsg) and gateway patterns"
read_when:
- Adding or changing external CLI integrations
- Debugging RPC adapters (signal-cli, imsg)
@@ -19,9 +19,11 @@ OpenClaw integrates external CLIs via JSON-RPC. Two patterns are used today.
See [Signal](/channels/signal) for setup and endpoints.
## Pattern B: stdio child process (imsg)
## Pattern B: stdio child process (legacy: imsg)
- OpenClaw spawns `imsg rpc` as a child process.
> **Note:** For new iMessage setups, use [BlueBubbles](/channels/bluebubbles) instead.
- OpenClaw spawns `imsg rpc` as a child process (legacy iMessage integration).
- JSON-RPC is line-delimited over stdin/stdout (one JSON object per line).
- No TCP port, no daemon required.
@@ -32,7 +34,7 @@ Core methods used:
- `send`
- `chats.list` (probe/diagnostics)
See [iMessage](/channels/imessage) for setup and addressing (`chat_id` preferred).
See [iMessage](/channels/imessage) for legacy setup and addressing (`chat_id` preferred).
## Adapter guidelines

View File

@@ -42,13 +42,6 @@ Then open `SOUL.md` together and talk about:
Write it down. Make it real.
## One-time system admin check
Since this is a new install, offer a choice:
1. Run the recommended host healthcheck using the `healthcheck` skill.
2. Skip for now (run later by saying “run healthcheck”).
## Connect (Optional)
Ask how they want to reach you:

View File

@@ -71,7 +71,8 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [Discord](/channels/discord)
- [Mattermost](/channels/mattermost) (plugin)
- [Signal](/channels/signal)
- [iMessage](/channels/imessage)
- [BlueBubbles (iMessage)](/channels/bluebubbles)
- [iMessage (legacy)](/channels/imessage)
- [Location parsing](/channels/location)
- [WebChat](/web/webchat)
- [Webhooks](/automation/webhook)

View File

@@ -80,6 +80,7 @@ Stored under `~/.openclaw/devices/`:
- Telegram: [Telegram](/channels/telegram)
- WhatsApp: [WhatsApp](/channels/whatsapp)
- Signal: [Signal](/channels/signal)
- iMessage: [iMessage](/channels/imessage)
- BlueBubbles (iMessage): [BlueBubbles](/channels/bluebubbles)
- iMessage (legacy): [iMessage](/channels/imessage)
- Discord: [Discord](/channels/discord)
- Slack: [Slack](/channels/slack)

View File

@@ -127,7 +127,8 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
- [Google Chat](/channels/googlechat): service account JSON + webhook audience.
- [Mattermost](/channels/mattermost) (plugin): bot token + base URL.
- [Signal](/channels/signal): optional `signal-cli` install + account config.
- [iMessage](/channels/imessage): local `imsg` CLI path + DB access.
- [BlueBubbles](/channels/bluebubbles): **recommended for iMessage**; server URL + password + webhook.
- [iMessage](/channels/imessage): legacy `imsg` CLI path + DB access.
- DM security: default is pairing. First DM sends a code; approve via `openclaw pairing approve <channel> <code>` or use allowlists.
6. **Daemon install**
@@ -329,5 +330,5 @@ will prompt to install it (npm or a local path) before it can be configured.
- macOS app onboarding: [Onboarding](/start/onboarding)
- Config reference: [Gateway configuration](/gateway/configuration)
- Providers: [WhatsApp](/channels/whatsapp), [Telegram](/channels/telegram), [Discord](/channels/discord), [Google Chat](/channels/googlechat), [Signal](/channels/signal), [iMessage](/channels/imessage)
- Providers: [WhatsApp](/channels/whatsapp), [Telegram](/channels/telegram), [Discord](/channels/discord), [Google Chat](/channels/googlechat), [Signal](/channels/signal), [BlueBubbles](/channels/bluebubbles) (iMessage), [iMessage](/channels/imessage) (legacy)
- Skills: [Skills](/tools/skills), [Skills config](/tools/skills-config)

View File

@@ -252,6 +252,7 @@ Core parameters:
Notes:
- Enable via `tools.web.fetch.enabled`.
- `maxChars` is clamped by `tools.web.fetch.maxCharsCap` (default 50000).
- Responses are cached (default 15 min).
- For JS-heavy sites, prefer the browser tool.
- See [Web tools](/tools/web) for setup.

View File

@@ -221,6 +221,7 @@ Fetch a URL and extract readable content.
fetch: {
enabled: true,
maxChars: 50000,
maxCharsCap: 50000,
timeoutSeconds: 30,
cacheTtlMinutes: 15,
maxRedirects: 3,
@@ -252,6 +253,7 @@ Notes:
- Firecrawl requests use bot-circumvention mode and cache results by default.
- `web_fetch` sends a Chrome-like User-Agent and `Accept-Language` by default; override `userAgent` if needed.
- `web_fetch` blocks private/internal hostnames and re-checks redirects (limit with `maxRedirects`).
- `maxChars` is clamped to `tools.web.fetch.maxCharsCap`.
- `web_fetch` is best-effort extraction; some sites will need the browser tool.
- See [Firecrawl](/tools/firecrawl) for key setup and service details.
- Responses are cached (default 15 minutes) to reduce repeated fetches.

View File

@@ -79,6 +79,11 @@ you revoke it with `openclaw devices revoke --device <id> --role <role>`. See
- Logs: live tail of gateway file logs with filter/export (`logs.tail`)
- Update: run a package/git update + restart (`update.run`) with a restart report
Cron jobs panel notes:
- For isolated jobs, delivery defaults to announce summary. You can switch to none if you want internal-only runs.
- Channel/target fields appear when announce is selected.
## Chat behavior
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.

View File

@@ -1,39 +1,39 @@
---
read_when:
- 调度后台任务或唤醒
- 配置需要与心跳一起运行或配合运行的自动化任务
- 决定计划任务使用心跳还是定时任务
summary: Gateway 网关调度器的定时任务与唤醒机制
- 配置需要与心跳一起或并行运行的自动化
- 在心跳和定时任务之间做选择
summary: Gateway网关调度器的定时任务与唤醒
title: 定时任务
x-i18n:
generated_at: "2026-02-03T07:44:30Z"
generated_at: "2026-02-01T19:37:32Z"
model: claude-opus-4-5
provider: pi
source_hash: d43268b0029f1b13d0825ddcc9c06a354987ea17ce02f3b5428a9c68bf936676
source_path: automation/cron-jobs.md
workflow: 15
workflow: 14
---
# 定时任务Gateway 网关调度器)
# 定时任务Gateway网关调度器
> **定时任务还是心跳?** 请参阅[定时任务与心跳对比](/automation/cron-vs-heartbeat)了解何时使用哪种方式。
定时任务是 Gateway 网关内置的调度器。它持久化任务,在正确的时间唤醒智能体,并可选择将输出发送回聊天。
定时任务是 Gateway网关内置的调度器。它持久化任务、在合适的时间唤醒智能体,并可选择将输出发送回聊天。
如果你需要"每天早上运行这个"或"20 分钟后触发智能体",定时任务就是实现机制。
如果你想要 _"每天早上运行"__"20 分钟后提醒智能体"_,定时任务就是对应的机制。
## 简要概述
- 定时任务运行在 **Gateway 网关内部**不是在模型内部)。
- 定时任务运行在 **Gateway网关内部**而非模型内部)。
- 任务持久化存储在 `~/.openclaw/cron/` 下,因此重启不会丢失计划。
- 两种执行方式:
- **主会话**将系统事件加入队列,然后在下一次心跳时运行。
- **隔离**:在 `cron:<jobId>` 中运行专用智能体回合,可选择发送输出
- 唤醒是一等功能:任务可以请求"立即唤醒"或"下次心跳"。
- **主会话**入队一个系统事件,然后在下一次心跳时运行。
- **隔离**:在 `cron:<jobId>` 中运行专用智能体轮次,可投递摘要(默认 announce或不投递
- 唤醒是一等功能:任务可以请求"立即唤醒"或"下次心跳"。
## 快速开始(可操作)
创建一个一次性提醒,验证它是否存在,然后立即运行:
创建一个一次性提醒,验证存在,然后立即运行:
```bash
openclaw cron add \
@@ -49,7 +49,7 @@ openclaw cron run <job-id> --force
openclaw cron runs --id <job-id>
```
调度一个带消息发送的循环隔离任务:
调度一个带投递功能的周期性隔离任务:
```bash
openclaw cron add \
@@ -58,168 +58,158 @@ openclaw cron add \
--tz "America/Los_Angeles" \
--session isolated \
--message "Summarize overnight updates." \
--deliver \
--announce \
--channel slack \
--to "channel:C1234567890"
```
## 工具调用等效项Gateway 网关定时任务工具)
## 工具调用等价形式Gateway网关定时任务工具
有关规范的 JSON 结构和示例,请参阅[工具调用的 JSON schema](/automation/cron-jobs#json-schema-for-tool-calls)。
有关规范的 JSON 结构和示例,请参阅[工具调用的 JSON 模式](/automation/cron-jobs#json-schema-for-tool-calls)。
## 定时任务的存储位置
定时任务默认持久化存储在 Gateway 网关主机的 `~/.openclaw/cron/jobs.json`。Gateway 网关将文件加载到内存中,并在更改时写回,因此只有在 Gateway 网关停止时手动编辑才是安全的。建议使用 `openclaw cron add/edit` 或定时任务工具调用 API 进行更改。
定时任务默认持久化存储在 Gateway网关主机的 `~/.openclaw/cron/jobs.json`。Gateway网关将文件加载到内存中并在更改时写回因此在 Gateway网关停止时手动编辑才是安全的。请优先使用 `openclaw cron add/edit` 或定时任务工具调用 API 进行更改。
## 新手友好概述
将定时任务理解为:**何时**运行 + **做什么**
1. **选择计划**
1. **选择调度计划**
- 一次性提醒 → `schedule.kind = "at"`CLI`--at`
- 重复任务 → `schedule.kind = "every"``schedule.kind = "cron"`
- 如果你的 ISO 时间戳省略了时区,将被视为 **UTC**
- 如果你的 ISO 时间戳省略了时区,将被视为 **UTC**
2. **选择运行位置**
- `sessionTarget: "main"` → 在下一次心跳时使用主上下文运行。
- `sessionTarget: "isolated"` → 在 `cron:<jobId>` 中运行专用智能体回合
- `sessionTarget: "main"` → 在下一次心跳时使用主会话上下文运行。
- `sessionTarget: "isolated"` → 在 `cron:<jobId>` 中运行专用智能体轮次
3. **选择负载**
- 主会话 → `payload.kind = "systemEvent"`
- 隔离会话 → `payload.kind = "agentTurn"`
可选:`deleteAfterRun: true` 会在成功行后从存储中删除一次性任务。
可选:一次性任务(`schedule.kind = "at"`)默认会在成功行后删除。设置
`deleteAfterRun: false` 可保留它(成功后会禁用)。
## 概念
### 任务
定时任务是一存储记录,包含:
定时任务是一存储记录,包含:
- 一个**计划**(何时运行),
- 一个**调度计划**(何时运行),
- 一个**负载**(做什么),
- 可选的**发送**(输出发送到哪里)。
- 可选的**智能体绑定**`agentId`):在定智能体下运行任务如果缺失或未知Gateway 网关会回退到默认智能体。
- 可选的**投递**(输出发送到哪里)。
- 可选的**智能体绑定**`agentId`):在定智能体下运行任务如果缺失或未知Gateway网关会回退到默认智能体。
任务通过稳定的 `jobId` 标识( CLI/Gateway 网关 API 使用)。在智能体工具调用中,`jobId` 是规范名称;为了兼容性也接受旧版的 `id`。任务可以通过 `deleteAfterRun: true` 选择在一次性成功运行后自动删除
任务通过稳定的 `jobId` 标识(用于 CLI/Gateway网关 API
在智能体工具调用中,`jobId` 是规范字段;旧版 `id` 仍可兼容使用。
一次性任务默认会在成功运行后自动删除;设置 `deleteAfterRun: false` 可保留它。
### 计划
### 调度计划
定时任务支持三种计划类型:
定时任务支持三种调度类型:
- `at`:一次性时间戳(自纪元以来的毫秒数。Gateway 网关接受 ISO 8601 并转换为 UTC
- `at`一次性时间戳ISO 8601 字符串)
- `every`:固定间隔(毫秒)。
- `cron`5 字段 cron 表达式,可选 IANA 时区。
- `cron`5 字段 cron 表达式,可选 IANA 时区。
Cron 表达式使用 `croner`。如果省略时区,使用 Gateway 网关主机的本地时区。
Cron 表达式使用 `croner`。如果省略时区,使用 Gateway网关主机的本地时区。
### 主会话与隔离执行
### 主会话与隔离执行
#### 主会话任务(系统事件)
任务将系统事件加入队列并可选择唤醒心跳运行器。它们必须使用 `payload.kind = "systemEvent"`
会话任务入队一个系统事件,并可选择唤醒心跳运行器。它们必须使用 `payload.kind = "systemEvent"`
- `wakeMode: "next-heartbeat"`(默认):事件等待下一次计划心跳。
- `wakeMode: "next-heartbeat"`(默认):事件等待下一次计划心跳。
- `wakeMode: "now"`:事件触发立即心跳运行。
当你需要正常的心跳提示 + 主会话上下文时,这是最佳选择。参见[心跳](/gateway/heartbeat)。
#### 隔离任务(专用定时会话)
隔离任务在会话 `cron:<jobId>` 中运行专用智能体回合
隔离任务在会话 `cron:<jobId>` 中运行专用智能体轮次
关键行为:
- 提示以 `[cron:<jobId> <job name>]` 为前缀以便追踪。
- 每次运行启动一个**新的会话 id**没有先前的对话延续)。
- 摘要会发布到主会话(前缀 `Cron`,可配置)
- `wakeMode: "now"` 在发布摘要后触发立即心跳
- 如果 `payload.deliver: true`,输出会发送到渠道;否则保持内部。
- 提示以 `[cron:<jobId> <任务名称>]` 为前缀,便于追踪。
- 每次运行都会启动一个**新的会话 ID**不继承之前的对话)。
- 如果未指定 `delivery`隔离任务会默认以“announce”方式投递摘要
- `delivery.mode` 可选 `announce`(投递摘要)或 `none`(内部运行)
对于嘈杂、频繁或不应该刷屏主聊天历史的"后台杂务",使用隔离任务。
对于嘈杂、频繁或"后台杂务"类任务,使用隔离任务可以避免污染你的主聊天记录
### 负载结构(运行什么
### 负载结构(运行内容
支持两种负载类型:
- `systemEvent`:仅限主会话,通过心跳提示路由。
- `agentTurn`:仅限隔离会话,运行专用智能体回合
- `agentTurn`:仅限隔离会话,运行专用智能体轮次
见的 `agentTurn` 字段:
`agentTurn` 字段:
- `message`:必需的文本提示。
- `message`:必文本提示。
- `model` / `thinking`:可选覆盖(见下文)。
- `timeoutSeconds`:可选超时覆盖。
- `deliver``true` 则将输出发送到渠道目标。
- `channel``last` 或特定渠道。
- `to`:特定于渠道的目标(电话/聊天/频道 id
- `bestEffortDeliver`:发送失败时避免任务失败。
- `timeoutSeconds`:可选超时覆盖。
隔离选项(仅适用于 `session=isolated`
### 模型和思维覆盖
- `postToMainPrefix`CLI`--post-prefix`):主会话中系统事件的前缀。
- `postToMainMode``summary`(默认)或 `full`
- `postToMainMaxChars`:当 `postToMainMode=full` 时的最大字符数(默认 8000
### 模型和思考覆盖
隔离任务(`agentTurn`)可以覆盖模型和思考级别:
隔离任务(`agentTurn`)可以覆盖模型和思维级别:
- `model`:提供商/模型字符串(例如 `anthropic/claude-sonnet-4-20250514`)或别名(例如 `opus`
- `thinking`:思级别(`off``minimal``low``medium``high``xhigh`;仅限 GPT-5.2 + Codex 模型)
- `thinking`:思级别(`off``minimal``low``medium``high``xhigh`;仅限 GPT-5.2 + Codex 模型)
注意:你也可以在主会话任务上设置 `model`,但会更改共享的主会话模型。我们建议仅对隔离任务使用模型覆盖,以避免意外的上下文切换。
注意:你也可以在主会话任务上设置 `model`,但会更改共享的主会话模型。我们建议仅对隔离任务使用模型覆盖,以避免意外的上下文切换。
解析优先级:
优先级解析顺序
1. 任务负载覆盖(最高)
1. 任务负载覆盖(最高优先级
2. 钩子特定默认值(例如 `hooks.gmail.model`
3. 智能体配置默认值
### 发送(渠道 + 目标)
### 投递(渠道 + 目标)
隔离任务可以将输出发送到渠道。任务负载可以指定
隔离任务可以通过顶层 `delivery` 配置投递输出
- `channel``whatsapp` / `telegram` / `discord` / `slack` / `mattermost`(插件)/ `signal` / `imessage` / `last`
- `to`:特定于渠道的接收者目标
- `delivery.mode``announce`(投递摘要)或 `none`
- `delivery.channel``whatsapp` / `telegram` / `discord` / `slack` / `mattermost`(插件)/ `signal` / `imessage` / `last`
- `delivery.to`:渠道特定的接收目标
- `delivery.bestEffort`:投递失败时避免任务失败
如果省略 `channel``to`,定时任务可以回退到主会话的"最后路由"(智能体最后回复的位置)
当启用 announce 投递时,该轮次会抑制消息工具发送;请使用 `delivery.channel`/`delivery.to` 来指定目标
发送说明:
- 如果设置了 `to`,即使省略了 `deliver`,定时任务也会自动发送智能体的最终输出。
- 当你想要不带显式 `to` 的最后路由发送时,使用 `deliver: true`
- 使用 `deliver: false` 即使存在 `to` 也保持输出在内部。
如果省略 `delivery.channel``delivery.to`,定时任务会回退到主会话的“最后路由”(智能体最后回复的位置)。
目标格式提醒:
- Slack/Discord/Mattermost插件目标应使用显式前缀(例如 `channel:<id>``user:<id>`)以避免歧义。
- Telegram 题应使用 `:topic:` 式(见下文)。
- Slack/Discord/Mattermost插件目标应使用明确前缀(例如 `channel:<id>``user:<id>`)以避免歧义。
- Telegram 题应使用 `:topic:` 式(见下文)。
#### Telegram 发送目标(题/论坛帖子)
#### Telegram 投递目标(题/论坛帖子)
Telegram 通过 `message_thread_id` 支持论坛题。对于定时任务发送,你可以将题/帖子编码到 `to` 字段中:
Telegram 通过 `message_thread_id` 支持论坛题。对于定时任务投递,你可以将题/帖子编码到 `to` 字段中:
- `-1001234567890`(仅聊天 id
- `-1001234567890:topic:123`(推荐:显式话题标记)
- `-1001234567890`(仅聊天 ID
- `-1001234567890:topic:123`(推荐:明确的主题标记)
- `-1001234567890:123`(简写:数字后缀)
带前缀的目标如 `telegram:...` / `telegram:group:...`接受:
带前缀的目标如 `telegram:...` / `telegram:group:...`接受:
- `telegram:group:-1001234567890:topic:123`
## 工具调用的 JSON schema
## 工具调用的 JSON 模式
直接调用 Gateway 网关 `cron.*` 工具(智能体工具调用或 RPC使用这些结构。CLI 标志接受人类可读的时间格式如 `20m`,但工具调用`atMs``everyMs` 使用纪元毫秒(`at` 时间接受 ISO 时间戳)
直接调用 Gateway网关 `cron.*` 工具(智能体工具调用或 RPC使用这些结构。CLI 标志接受人类可读的时间格式如 `20m`,但工具调用应使用 ISO 8601 字符串作为 `schedule.at`,并使用毫秒作为 `schedule.everyMs`
### cron.add 参数
一次性主会话任务(系统事件):
一次性主会话任务(系统事件):
```json
{
"name": "Reminder",
"schedule": { "kind": "at", "atMs": 1738262400000 },
"schedule": { "kind": "at", "at": "2026-02-01T16:00:00Z" },
"sessionTarget": "main",
"wakeMode": "now",
"payload": { "kind": "systemEvent", "text": "Reminder text" },
@@ -227,7 +217,7 @@ Telegram 通过 `message_thread_id` 支持论坛话题。对于定时任务发
}
```
循环,带发送的隔离任务:
带投递的周期性隔离任务:
```json
{
@@ -237,22 +227,24 @@ Telegram 通过 `message_thread_id` 支持论坛话题。对于定时任务发
"wakeMode": "next-heartbeat",
"payload": {
"kind": "agentTurn",
"message": "Summarize overnight updates.",
"deliver": true,
"message": "Summarize overnight updates."
},
"delivery": {
"mode": "announce",
"channel": "slack",
"to": "channel:C1234567890",
"bestEffortDeliver": true
},
"isolation": { "postToMainPrefix": "Cron", "postToMainMode": "summary" }
"bestEffort": true
}
}
```
说明:
- `schedule.kind``at``atMs`)、`every``everyMs`)或 `cron``expr`,可选 `tz`)。
- `atMs``everyMs` 是纪元毫秒
- `sessionTarget` 必须是 `"main"``"isolated"` 并且必须与 `payload.kind` 匹配
- 可选字段:`agentId``description``enabled``deleteAfterRun``isolation`
- `schedule.kind``at``at`)、`every``everyMs`)或 `cron``expr`,可选 `tz`)。
- `schedule.at` 接受 ISO 8601可省略时区省略时按 UTC 处理)
- `everyMs` 为毫秒数
- `sessionTarget` 必须为 `"main"``"isolated"`,且必须与 `payload.kind` 匹配
- 可选字段:`agentId``description``enabled``deleteAfterRun``delivery`
- `wakeMode` 省略时默认为 `"next-heartbeat"`
### cron.update 参数
@@ -269,8 +261,8 @@ Telegram 通过 `message_thread_id` 支持论坛话题。对于定时任务发
说明:
- `jobId` 是规范名称;为了兼容性也接受 `id`
- 在补丁中使用 `agentId: null` 清除智能体绑定。
- `jobId` 是规范字段;`id` 可兼容使用
- 在补丁中使用 `agentId: null` 清除智能体绑定。
### cron.run 和 cron.remove 参数
@@ -282,9 +274,9 @@ Telegram 通过 `message_thread_id` 支持论坛话题。对于定时任务发
{ "jobId": "job-123" }
```
## 存储历史
## 存储历史
- 任务存储:`~/.openclaw/cron/jobs.json`Gateway 网关管理的 JSON
- 任务存储:`~/.openclaw/cron/jobs.json`Gateway网关管理的 JSON
- 运行历史:`~/.openclaw/cron/runs/<jobId>.jsonl`JSONL自动清理
- 覆盖存储路径:配置中的 `cron.store`
@@ -330,7 +322,7 @@ openclaw cron add \
--wake now
```
循环隔离任务(发送到 WhatsApp
周期性隔离任务(投递到 WhatsApp
```bash
openclaw cron add \
@@ -339,12 +331,12 @@ openclaw cron add \
--tz "America/Los_Angeles" \
--session isolated \
--message "Summarize inbox + calendar for today." \
--deliver \
--announce \
--channel whatsapp \
--to "+15551234567"
```
循环隔离任务(发送到 Telegram 题):
周期性隔离任务(投递到 Telegram 题):
```bash
openclaw cron add \
@@ -353,12 +345,12 @@ openclaw cron add \
--tz "America/Los_Angeles" \
--session isolated \
--message "Summarize today; send to the nightly topic." \
--deliver \
--announce \
--channel telegram \
--to "-1001234567890:topic:123"
```
带模型和思覆盖的隔离任务:
带模型和思覆盖的隔离任务:
```bash
openclaw cron add \
@@ -369,15 +361,15 @@ openclaw cron add \
--message "Weekly deep analysis of project progress." \
--model "opus" \
--thinking high \
--deliver \
--announce \
--channel whatsapp \
--to "+15551234567"
```
智能体选择(多智能体置):
智能体选择(多智能体置):
```bash
# 将任务绑定到智能体"ops"(如果该智能体不存在则回退到默认)
# 将任务绑定到智能体 "ops"(如果该智能体不存在则回退到默认智能体
openclaw cron add --name "Ops sweep" --cron "0 6 * * *" --session isolated --message "Check ops queue" --agent ops
# 切换或清除现有任务的智能体
@@ -406,27 +398,27 @@ openclaw cron edit <jobId> \
openclaw cron runs --id <jobId> --limit 50
```
不创建任务的立即系统事件:
不创建任务直接发送系统事件:
```bash
openclaw system event --mode now --text "Next heartbeat: check battery."
```
## Gateway 网关 API 接口
## Gateway网关 API 接口
- `cron.list``cron.status``cron.add``cron.update``cron.remove`
- `cron.run`(强制或到期)、`cron.runs`
对于不创建任务的立即系统事件,使用 [`openclaw system event`](/cli/system)。
如需不创建任务直接发送系统事件,使用 [`openclaw system event`](/cli/system)。
## 故障排除
### "什么都不运行"
### "没有任何任务运行"
- 检查定时任务是否启用:`cron.enabled``OPENCLAW_SKIP_CRON`
- 检查 Gateway 网关是否持续运行(定时任务在 Gateway 网关进程内运行)。
- 对于 `cron` 计划:确认时区(`--tz`)与主机时区的关系。
- 检查定时任务是否启用:`cron.enabled``OPENCLAW_SKIP_CRON`
- 检查 Gateway网关是否持续运行定时任务运行在 Gateway网关进程内)。
- 对于 `cron` 调度:确认时区(`--tz`)与主机时区的关系。
### Telegram 发送到错误的位置
### Telegram 投递到了错误的位置
- 对于论坛题,使用 `-100…:topic:<id>` 以确保明确无歧义。
- 如果你在日志或存储的"最后路由"目标中看到 `telegram:...` 前缀,这是正常的;定时任务发送接受它们并仍正确解析题 ID。
- 对于论坛题,使用 `-100…:topic:<id>` 以确保明确无歧义。
- 如果你在日志或存储的"最后路由"目标中看到 `telegram:...` 前缀,这是正常的;定时任务投递接受这些前缀并仍正确解析题 ID。

View File

@@ -97,7 +97,7 @@ x-i18n:
- **精确定时**:支持带时区的 5 字段 cron 表达式。
- **会话隔离**:在 `cron:<jobId>` 中运行,不会污染主会话历史。
- **模型覆盖**:可按任务使用更便宜或更强大的模型。
- **投递控制**可直接投递到渠道;默认仍会向主会话发布摘要(可配置)
- **投递控制**隔离任务默认以 `announce` 投递摘要,可选 `none` 仅内部运行
- **无需智能体上下文**:即使主会话空闲或已压缩,也能运行。
- **一次性支持**`--at` 用于精确的未来时间戳。
@@ -111,7 +111,7 @@ openclaw cron add \
--session isolated \
--message "Generate today's briefing: weather, calendar, top emails, news summary." \
--model opus \
--deliver \
--announce \
--channel whatsapp \
--to "+15551234567"
```
@@ -180,7 +180,7 @@ openclaw cron add \
```bash
# 每天早上 7 点的早间简报
openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --deliver
openclaw cron add --name "Morning brief" --cron "0 7 * * *" --session isolated --message "..." --announce
# 每周一上午 9 点的项目回顾
openclaw cron add --name "Weekly review" --cron "0 9 * * 1" --session isolated --message "..." --model opus
@@ -219,13 +219,13 @@ Lobster 是用于**多步骤工具管道**的工作流运行时,适用于需
心跳和定时任务都可以与主会话交互,但方式不同:
| | 心跳 | 定时任务(主会话) | 定时任务(隔离式) |
| ------ | ------------------------ | ---------------------- | ------------------ |
| 会话 | 主会话 | 主会话(通过系统事件) | `cron:<jobId>` |
| 历史 | 共享 | 共享 | 每次运行全新 |
| 上下文 | 完整 | 完整 | 无(从零开始) |
| 模型 | 主会话模型 | 主会话模型 | 可覆盖 |
| 输出 | 非 `HEARTBEAT_OK` 时投递 | 心跳提示 + 事件 | 摘要发布到主会话 |
| | 心跳 | 定时任务(主会话) | 定时任务(隔离式) |
| ------ | ------------------------ | ---------------------- | --------------------- |
| 会话 | 主会话 | 主会话(通过系统事件) | `cron:<jobId>` |
| 历史 | 共享 | 共享 | 每次运行全新 |
| 上下文 | 完整 | 完整 | 无(从零开始) |
| 模型 | 主会话模型 | 主会话模型 | 可覆盖 |
| 输出 | 非 `HEARTBEAT_OK` 时投递 | 心跳提示 + 事件 | announce 摘要(默认) |
### 何时使用主会话定时任务
@@ -250,7 +250,7 @@ openclaw cron add \
- 无先前上下文的全新环境
- 不同的模型或思维设置
- 输出直接投递到渠道(摘要默认仍会发布到主会话
- 输出可通过 `announce` 直接投递摘要(或用 `none` 仅内部运行
- 不会把主会话搞得杂乱的历史记录
```bash
@@ -261,7 +261,7 @@ openclaw cron add \
--message "Weekly codebase analysis..." \
--model opus \
--thinking high \
--deliver
--announce
```
## 成本考量

View File

@@ -28,8 +28,8 @@ OpenClaw 可以在你已经使用的任何聊天应用上与你交流。每个
- [Google Chat](/channels/googlechat) — 通过 HTTP webhook 的 Google Chat API 应用。
- [Mattermost](/channels/mattermost) — Bot API + WebSocket频道、群组、私信插件需单独安装
- [Signal](/channels/signal) — signal-cli注重隐私。
- [BlueBubbles](/channels/bluebubbles) — **iMessage 推荐方案**;使用 BlueBubbles macOS 服务器 REST API完整功能支持(编辑、撤回、特效、表情回应、群组管理——编辑功能目前在 macOS 26 Tahoe 上存在问题)。
- [iMessage](/channels/imessage) — 仅限 macOS通过 imsg 原生集成(旧版方案,新部署建议使用 BlueBubbles
- [BlueBubbles](/channels/bluebubbles) — **推荐用于 iMessage**;使用 BlueBubbles macOS 服务器 REST API功能完整(编辑、撤回、特效、回应、群组管理——编辑功能在 macOS 26 Tahoe 上目前不可用)。
- [iMessage(旧版)](/channels/imessage) — 通过 imsg CLI 的旧版 macOS 集成(已弃用,新设置请使用 BlueBubbles
- [Microsoft Teams](/channels/msteams) — Bot Framework企业支持插件需单独安装
- [LINE](/channels/line) — LINE Messaging API 机器人(插件,需单独安装)。
- [Nextcloud Talk](/channels/nextcloud-talk) — 通过 Nextcloud Talk 的自托管聊天(插件,需单独安装)。

View File

@@ -23,12 +23,17 @@ x-i18n:
提示:运行 `openclaw cron --help` 查看完整的命令集。
## 常用编辑
说明:隔离式 `cron add` 任务默认使用 `--announce` 投递摘要。使用 `--no-deliver` 仅内部运行。
`--deliver` 仍作为 `--announce` 的弃用别名保留。
说明:一次性(`--at`)任务成功后默认删除。使用 `--keep-after-run` 保留。
## 常见编辑
更新投递设置而不更改消息:
```bash
openclaw cron edit <job-id> --deliver --channel telegram --to "123456789"
openclaw cron edit <job-id> --announce --channel telegram --to "123456789"
```
为隔离的作业禁用投递:

View File

@@ -1008,7 +1008,7 @@ pnpm add -g clawhub
**能否从 Linux 运行仅限 Apple/macOS 的 Skills**
不能直接运行。macOS Skills 受 `metadata.openclaw.os` 和所需二进制文件限制Skills 只有在 **Gateway 网关主机**上符合条件时才会出现在系统提示中。在 Linux 上,`darwin` 专用 Skills如 `imsg`、`apple-notes`、`apple-reminders`)不会加载,除非你覆盖限制。
不能直接运行。macOS Skills 受 `metadata.openclaw.os` 和所需二进制文件限制Skills 只有在 **Gateway 网关主机**上符合条件时才会出现在系统提示中。在 Linux 上,`darwin` 专用 Skills如 `apple-notes`、`apple-reminders`、`things-mac`)不会加载,除非你覆盖限制。
你有三种支持的模式:

View File

@@ -0,0 +1,45 @@
# BlueBubbles extension (developer reference)
This directory contains the **BlueBubbles external channel plugin** for OpenClaw.
If youre looking for **how to use BlueBubbles as an agent/tool user**, see:
- `skills/bluebubbles/SKILL.md`
## Layout
- Extension package: `extensions/bluebubbles/` (entry: `index.ts`).
- Channel implementation: `extensions/bluebubbles/src/channel.ts`.
- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register via `api.registerHttpHandler`).
- REST helpers: `extensions/bluebubbles/src/send.ts` + `extensions/bluebubbles/src/probe.ts`.
- Runtime bridge: `extensions/bluebubbles/src/runtime.ts` (set via `api.runtime`).
- Catalog entry for onboarding: `src/channels/plugins/catalog.ts`.
## Internal helpers (use these, not raw API calls)
- `probeBlueBubbles` in `extensions/bluebubbles/src/probe.ts` for health checks.
- `sendMessageBlueBubbles` in `extensions/bluebubbles/src/send.ts` for text delivery.
- `resolveChatGuidForTarget` in `extensions/bluebubbles/src/send.ts` for chat lookup.
- `sendBlueBubblesReaction` in `extensions/bluebubbles/src/reactions.ts` for tapbacks.
- `sendBlueBubblesTyping` + `markBlueBubblesChatRead` in `extensions/bluebubbles/src/chat.ts`.
- `downloadBlueBubblesAttachment` in `extensions/bluebubbles/src/attachments.ts` for inbound media.
- `buildBlueBubblesApiUrl` + `blueBubblesFetchWithTimeout` in `extensions/bluebubbles/src/types.ts` for shared REST plumbing.
## Webhooks
- BlueBubbles posts JSON to the gateway HTTP server.
- Normalize sender/chat IDs defensively (payloads vary by version).
- Skip messages marked as from self.
- Route into core reply pipeline via the plugin runtime (`api.runtime`) and `openclaw/plugin-sdk` helpers.
- For attachments/stickers, use `<media:...>` placeholders when text is empty and attach media paths via `MediaUrl(s)` in the inbound context.
## Config (core)
- `channels.bluebubbles.serverUrl` (base URL), `channels.bluebubbles.password`, `channels.bluebubbles.webhookPath`.
- Action gating: `channels.bluebubbles.actions.reactions` (default true).
## Message tool notes
- **Reactions:** the `react` action requires a `target` (phone number or chat identifier) in addition to `messageId`.
Example:
`action=react target=+15551234567 messageId=ABC123 emoji=❤️`

View File

@@ -0,0 +1,47 @@
# @openclaw/feishu
Feishu/Lark channel plugin for OpenClaw (WebSocket bot events).
## Install (local checkout)
```bash
openclaw plugins install ./extensions/feishu
```
## Install (npm)
```bash
openclaw plugins install @openclaw/feishu
```
Onboarding: select Feishu/Lark and confirm the install prompt to fetch the plugin automatically.
## Config
```json5
{
channels: {
feishu: {
accounts: {
default: {
appId: "cli_xxx",
appSecret: "xxx",
domain: "feishu",
enabled: true,
},
},
dmPolicy: "pairing",
groupPolicy: "open",
blockStreaming: true,
},
},
}
```
Lark (global) tenants should set `domain: "lark"` (or a full https:// domain).
Restart the gateway after config changes.
## Docs
https://docs.openclaw.ai/channels/feishu

View File

@@ -8,9 +8,9 @@ import {
} from "openclaw/plugin-sdk";
import type { CoreConfig, DmPolicy } from "./types.js";
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
import { listMatrixDirectoryPeersLive } from "./directory-live.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js";
import { resolveMatrixTargets } from "./resolve-targets.js";
const channel = "matrix" as const;
@@ -65,14 +65,16 @@ async function promptMatrixAllowFrom(params: {
while (true) {
const entry = await prompter.text({
message: "Matrix allowFrom (username or user id)",
message: "Matrix allowFrom (full @user:server; display name only if unique)",
placeholder: "@user:server",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = parseInput(String(entry));
const resolvedIds: string[] = [];
let unresolved: string[] = [];
const pending: string[] = [];
const unresolved: string[] = [];
const unresolvedNotes: string[] = [];
for (const part of parts) {
if (isFullUserId(part)) {
@@ -83,28 +85,33 @@ async function promptMatrixAllowFrom(params: {
unresolved.push(part);
continue;
}
const results = await listMatrixDirectoryPeersLive({
pending.push(part);
}
if (pending.length > 0) {
const results = await resolveMatrixTargets({
cfg,
query: part,
limit: 5,
inputs: pending,
kind: "user",
}).catch(() => []);
const match = results.find((result) => result.id);
if (match?.id) {
resolvedIds.push(match.id);
if (results.length > 1) {
await prompter.note(
`Multiple matches for "${part}", using ${match.id}.`,
"Matrix allowlist",
);
for (const result of results) {
if (result?.resolved && result.id) {
resolvedIds.push(result.id);
continue;
}
if (result?.input) {
unresolved.push(result.input);
if (result.note) {
unresolvedNotes.push(`${result.input}: ${result.note}`);
}
}
} else {
unresolved.push(part);
}
}
if (unresolved.length > 0) {
const details = unresolvedNotes.length > 0 ? unresolvedNotes : unresolved;
await prompter.note(
`Could not resolve: ${unresolved.join(", ")}. Use full @user:server IDs.`,
`Could not resolve:\n${details.join("\n")}\nUse full @user:server IDs.`,
"Matrix allowlist",
);
continue;

View File

@@ -121,7 +121,6 @@ export async function handleNextcloudTalkInbound(params: {
const senderAllowedForCommands = resolveNextcloudTalkAllowlistMatch({
allowFrom: isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
senderId,
senderName,
}).allowed;
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
const commandGate = resolveControlCommandGate({
@@ -143,7 +142,6 @@ export async function handleNextcloudTalkInbound(params: {
outerAllowFrom: effectiveGroupAllowFrom,
innerAllowFrom: roomAllowFrom,
senderId,
senderName,
});
if (!groupAllow.allowed) {
runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (policy=${groupPolicy})`);
@@ -158,7 +156,6 @@ export async function handleNextcloudTalkInbound(params: {
const dmAllowed = resolveNextcloudTalkAllowlistMatch({
allowFrom: effectiveAllowFrom,
senderId,
senderName,
}).allowed;
if (!dmAllowed) {
if (dmPolicy === "pairing") {

View File

@@ -54,7 +54,7 @@ function payloadToInboundMessage(
roomToken: payload.target.id,
roomName: payload.target.name,
senderId: payload.actor.id,
senderName: payload.actor.name,
senderName: payload.actor.name ?? "",
text: payload.object.content || payload.object.name || "",
mediaType: payload.object.mediaType || "text/plain",
timestamp: Date.now(),

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { resolveNextcloudTalkAllowlistMatch } from "./policy.js";
describe("nextcloud-talk policy", () => {
describe("resolveNextcloudTalkAllowlistMatch", () => {
it("allows wildcard", () => {
expect(
resolveNextcloudTalkAllowlistMatch({
allowFrom: ["*"],
senderId: "user-id",
}).allowed,
).toBe(true);
});
it("allows sender id match with normalization", () => {
expect(
resolveNextcloudTalkAllowlistMatch({
allowFrom: ["nc:User-Id"],
senderId: "user-id",
}),
).toEqual({ allowed: true, matchKey: "user-id", matchSource: "id" });
});
it("blocks when sender id does not match", () => {
expect(
resolveNextcloudTalkAllowlistMatch({
allowFrom: ["allowed"],
senderId: "other",
}).allowed,
).toBe(false);
});
});
});

View File

@@ -29,8 +29,7 @@ export function normalizeNextcloudTalkAllowlist(
export function resolveNextcloudTalkAllowlistMatch(params: {
allowFrom: Array<string | number> | undefined;
senderId: string;
senderName?: string | null;
}): AllowlistMatch<"wildcard" | "id" | "name"> {
}): AllowlistMatch<"wildcard" | "id"> {
const allowFrom = normalizeNextcloudTalkAllowlist(params.allowFrom);
if (allowFrom.length === 0) {
return { allowed: false };
@@ -42,10 +41,6 @@ export function resolveNextcloudTalkAllowlistMatch(params: {
if (allowFrom.includes(senderId)) {
return { allowed: true, matchKey: senderId, matchSource: "id" };
}
const senderName = params.senderName ? normalizeAllowEntry(params.senderName) : "";
if (senderName && allowFrom.includes(senderName)) {
return { allowed: true, matchKey: senderName, matchSource: "name" };
}
return { allowed: false };
}
@@ -132,7 +127,6 @@ export function resolveNextcloudTalkGroupAllow(params: {
outerAllowFrom: Array<string | number> | undefined;
innerAllowFrom: Array<string | number> | undefined;
senderId: string;
senderName?: string | null;
}): { allowed: boolean; outerMatch: AllowlistMatch; innerMatch: AllowlistMatch } {
if (params.groupPolicy === "disabled") {
return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } };
@@ -150,12 +144,10 @@ export function resolveNextcloudTalkGroupAllow(params: {
const outerMatch = resolveNextcloudTalkAllowlistMatch({
allowFrom: params.outerAllowFrom,
senderId: params.senderId,
senderName: params.senderName,
});
const innerMatch = resolveNextcloudTalkAllowlistMatch({
allowFrom: params.innerAllowFrom,
senderId: params.senderId,
senderName: params.senderName,
});
const allowed = resolveNestedAllowlistDecision({
outerConfigured: outerAllow.length > 0 || innerAllow.length > 0,

View File

@@ -25,6 +25,7 @@ import {
type ChannelPlugin,
type OpenClawConfig,
type ResolvedTelegramAccount,
type TelegramProbe,
} from "openclaw/plugin-sdk";
import { getTelegramRuntime } from "./runtime.js";
@@ -60,7 +61,7 @@ function parseThreadId(threadId?: string | number | null) {
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProbe> = {
id: "telegram",
meta: {
...meta,
@@ -327,11 +328,7 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
if (!groupIds.length && unresolvedGroups === 0 && !hasWildcardUnmentionedGroups) {
return undefined;
}
const botId =
(probe as { ok?: boolean; bot?: { id?: number } })?.ok &&
(probe as { bot?: { id?: number } }).bot?.id != null
? (probe as { bot: { id: number } }).bot.id
: null;
const botId = probe?.ok && probe.bot?.id != null ? probe.bot.id : null;
if (!botId) {
return {
ok: unresolvedGroups === 0 && !hasWildcardUnmentionedGroups,
@@ -357,15 +354,9 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ??
cfg.channels?.telegram?.groups;
const allowUnmentionedGroups =
Boolean(
groups?.["*"] && (groups["*"] as { requireMention?: boolean }).requireMention === false,
) ||
groups?.["*"]?.requireMention === false ||
Object.entries(groups ?? {}).some(
([key, value]) =>
key !== "*" &&
Boolean(value) &&
typeof value === "object" &&
(value as { requireMention?: boolean }).requireMention === false,
([key, value]) => key !== "*" && value?.requireMention === false,
);
return {
accountId: account.accountId,

View File

@@ -17,6 +17,11 @@ function createBaseConfig(provider: "telnyx" | "twilio" | "plivo" | "mock"): Voi
serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
tailscale: { mode: "off", path: "/voice/webhook" },
tunnel: { provider: "none", allowNgrokFreeTierLoopbackBypass: false },
webhookSecurity: {
allowedHosts: [],
trustForwardingHeaders: false,
trustedProxyIPs: [],
},
streaming: {
enabled: false,
sttProvider: "openai-realtime",

View File

@@ -211,16 +211,37 @@ export const VoiceCallTunnelConfigSchema = z
* will be allowed only for loopback requests (ngrok local agent).
*/
allowNgrokFreeTierLoopbackBypass: z.boolean().default(false),
/**
* Legacy ngrok free tier compatibility mode (deprecated).
* Use allowNgrokFreeTierLoopbackBypass instead.
*/
allowNgrokFreeTier: z.boolean().optional(),
})
.strict()
.default({ provider: "none", allowNgrokFreeTierLoopbackBypass: false });
export type VoiceCallTunnelConfig = z.infer<typeof VoiceCallTunnelConfigSchema>;
// -----------------------------------------------------------------------------
// Webhook Security Configuration
// -----------------------------------------------------------------------------
export const VoiceCallWebhookSecurityConfigSchema = z
.object({
/**
* Allowed hostnames for webhook URL reconstruction.
* Only these hosts are accepted from forwarding headers.
*/
allowedHosts: z.array(z.string().min(1)).default([]),
/**
* Trust X-Forwarded-* headers without a hostname allowlist.
* WARNING: Only enable if you trust your proxy configuration.
*/
trustForwardingHeaders: z.boolean().default(false),
/**
* Trusted proxy IP addresses. Forwarded headers are only trusted when
* the remote IP matches one of these addresses.
*/
trustedProxyIPs: z.array(z.string().min(1)).default([]),
})
.strict()
.default({ allowedHosts: [], trustForwardingHeaders: false, trustedProxyIPs: [] });
export type WebhookSecurityConfig = z.infer<typeof VoiceCallWebhookSecurityConfigSchema>;
// -----------------------------------------------------------------------------
// Outbound Call Configuration
// -----------------------------------------------------------------------------
@@ -339,6 +360,9 @@ export const VoiceCallConfigSchema = z
/** Tunnel configuration (unified ngrok/tailscale) */
tunnel: VoiceCallTunnelConfigSchema,
/** Webhook signature reconstruction and proxy trust configuration */
webhookSecurity: VoiceCallWebhookSecurityConfigSchema,
/** Real-time audio streaming configuration */
streaming: VoiceCallStreamingConfigSchema,
@@ -409,10 +433,21 @@ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig
allowNgrokFreeTierLoopbackBypass: false,
};
resolved.tunnel.allowNgrokFreeTierLoopbackBypass =
resolved.tunnel.allowNgrokFreeTierLoopbackBypass || resolved.tunnel.allowNgrokFreeTier || false;
resolved.tunnel.allowNgrokFreeTierLoopbackBypass ?? false;
resolved.tunnel.ngrokAuthToken = resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN;
resolved.tunnel.ngrokDomain = resolved.tunnel.ngrokDomain ?? process.env.NGROK_DOMAIN;
// Webhook Security Config
resolved.webhookSecurity = resolved.webhookSecurity ?? {
allowedHosts: [],
trustForwardingHeaders: false,
trustedProxyIPs: [],
};
resolved.webhookSecurity.allowedHosts = resolved.webhookSecurity.allowedHosts ?? [];
resolved.webhookSecurity.trustForwardingHeaders =
resolved.webhookSecurity.trustForwardingHeaders ?? false;
resolved.webhookSecurity.trustedProxyIPs = resolved.webhookSecurity.trustedProxyIPs ?? [];
return resolved;
}

View File

@@ -1,5 +1,5 @@
import crypto from "node:crypto";
import type { PlivoConfig } from "../config.js";
import type { PlivoConfig, WebhookSecurityConfig } from "../config.js";
import type {
HangupCallInput,
InitiateCallInput,
@@ -23,6 +23,8 @@ export interface PlivoProviderOptions {
skipVerification?: boolean;
/** Outbound ring timeout in seconds */
ringTimeoutSec?: number;
/** Webhook security options (forwarded headers/allowlist) */
webhookSecurity?: WebhookSecurityConfig;
}
type PendingSpeak = { text: string; locale?: string };
@@ -92,6 +94,10 @@ export class PlivoProvider implements VoiceCallProvider {
const result = verifyPlivoWebhook(ctx, this.authToken, {
publicUrl: this.options.publicUrl,
skipVerification: this.options.skipVerification,
allowedHosts: this.options.webhookSecurity?.allowedHosts,
trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders,
trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs,
remoteIP: ctx.remoteAddress,
});
if (!result.ok) {
@@ -112,7 +118,7 @@ export class PlivoProvider implements VoiceCallProvider {
// Keep providerCallId mapping for later call control.
const callUuid = parsed.get("CallUUID") || undefined;
if (callUuid) {
const webhookBase = PlivoProvider.baseWebhookUrlFromCtx(ctx);
const webhookBase = this.baseWebhookUrlFromCtx(ctx);
if (webhookBase) {
this.callUuidToWebhookUrl.set(callUuid, webhookBase);
}
@@ -444,7 +450,7 @@ export class PlivoProvider implements VoiceCallProvider {
ctx: WebhookContext,
opts: { flow: string; callId?: string },
): string | null {
const base = PlivoProvider.baseWebhookUrlFromCtx(ctx);
const base = this.baseWebhookUrlFromCtx(ctx);
if (!base) {
return null;
}
@@ -458,9 +464,16 @@ export class PlivoProvider implements VoiceCallProvider {
return u.toString();
}
private static baseWebhookUrlFromCtx(ctx: WebhookContext): string | null {
private baseWebhookUrlFromCtx(ctx: WebhookContext): string | null {
try {
const u = new URL(reconstructWebhookUrl(ctx));
const u = new URL(
reconstructWebhookUrl(ctx, {
allowedHosts: this.options.webhookSecurity?.allowedHosts,
trustForwardingHeaders: this.options.webhookSecurity?.trustForwardingHeaders,
trustedProxyIPs: this.options.webhookSecurity?.trustedProxyIPs,
remoteIP: ctx.remoteAddress,
}),
);
return `${u.origin}${u.pathname}`;
} catch {
return null;

View File

@@ -1,5 +1,5 @@
import crypto from "node:crypto";
import type { TwilioConfig } from "../config.js";
import type { TwilioConfig, WebhookSecurityConfig } from "../config.js";
import type { MediaStreamHandler } from "../media-stream.js";
import type { TelephonyTtsProvider } from "../telephony-tts.js";
import type {
@@ -38,6 +38,8 @@ export interface TwilioProviderOptions {
streamPath?: string;
/** Skip webhook signature verification (development only) */
skipVerification?: boolean;
/** Webhook security options (forwarded headers/allowlist) */
webhookSecurity?: WebhookSecurityConfig;
}
export class TwilioProvider implements VoiceCallProvider {

View File

@@ -12,6 +12,10 @@ export function verifyTwilioProviderWebhook(params: {
publicUrl: params.currentPublicUrl || undefined,
allowNgrokFreeTierLoopbackBypass: params.options.allowNgrokFreeTierLoopbackBypass ?? false,
skipVerification: params.options.skipVerification,
allowedHosts: params.options.webhookSecurity?.allowedHosts,
trustForwardingHeaders: params.options.webhookSecurity?.trustForwardingHeaders,
trustedProxyIPs: params.options.webhookSecurity?.trustedProxyIPs,
remoteIP: params.ctx.remoteAddress,
});
if (!result.ok) {

View File

@@ -44,7 +44,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
const allowNgrokFreeTierLoopbackBypass =
config.tunnel?.provider === "ngrok" &&
isLoopbackBind(config.serve?.bind) &&
(config.tunnel?.allowNgrokFreeTierLoopbackBypass || config.tunnel?.allowNgrokFreeTier || false);
(config.tunnel?.allowNgrokFreeTierLoopbackBypass ?? false);
switch (config.provider) {
case "telnyx":
@@ -70,6 +70,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
publicUrl: config.publicUrl,
skipVerification: config.skipSignatureVerification,
streamPath: config.streaming?.enabled ? config.streaming.streamPath : undefined,
webhookSecurity: config.webhookSecurity,
},
);
case "plivo":
@@ -82,6 +83,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
publicUrl: config.publicUrl,
skipVerification: config.skipSignatureVerification,
ringTimeoutSec: Math.max(1, Math.floor(config.ringTimeoutMs / 1000)),
webhookSecurity: config.webhookSecurity,
},
);
case "mock":

View File

@@ -197,7 +197,7 @@ describe("verifyTwilioWebhook", () => {
expect(result.ok).toBe(true);
});
it("rejects invalid signatures even with ngrok free tier enabled", () => {
it("rejects invalid signatures even when attacker injects forwarded host", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
@@ -212,14 +212,13 @@ describe("verifyTwilioWebhook", () => {
rawBody: postBody,
url: "http://127.0.0.1:3334/voice/webhook",
method: "POST",
remoteAddress: "203.0.113.10",
},
authToken,
{ allowNgrokFreeTierLoopbackBypass: true },
);
expect(result.ok).toBe(false);
expect(result.isNgrokFreeTier).toBe(true);
// X-Forwarded-Host is ignored by default, so URL uses Host header
expect(result.isNgrokFreeTier).toBe(false);
expect(result.reason).toMatch(/Invalid signature/);
});
@@ -248,4 +247,131 @@ describe("verifyTwilioWebhook", () => {
expect(result.isNgrokFreeTier).toBe(true);
expect(result.reason).toMatch(/compatibility mode/);
});
it("ignores attacker X-Forwarded-Host without allowedHosts or trustForwardingHeaders", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
// Attacker tries to inject their host - should be ignored
const result = verifyTwilioWebhook(
{
headers: {
host: "legitimate.example.com",
"x-forwarded-host": "attacker.evil.com",
"x-twilio-signature": "invalid",
},
rawBody: postBody,
url: "http://localhost:3000/voice/webhook",
method: "POST",
},
authToken,
);
expect(result.ok).toBe(false);
// Attacker's host is ignored - uses Host header instead
expect(result.verificationUrl).toBe("https://legitimate.example.com/voice/webhook");
});
it("uses X-Forwarded-Host when allowedHosts whitelist is provided", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
const webhookUrl = "https://myapp.ngrok.io/voice/webhook";
const signature = twilioSignature({ authToken, url: webhookUrl, postBody });
const result = verifyTwilioWebhook(
{
headers: {
host: "localhost:3000",
"x-forwarded-proto": "https",
"x-forwarded-host": "myapp.ngrok.io",
"x-twilio-signature": signature,
},
rawBody: postBody,
url: "http://localhost:3000/voice/webhook",
method: "POST",
},
authToken,
{ allowedHosts: ["myapp.ngrok.io"] },
);
expect(result.ok).toBe(true);
expect(result.verificationUrl).toBe(webhookUrl);
});
it("rejects X-Forwarded-Host not in allowedHosts whitelist", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
const result = verifyTwilioWebhook(
{
headers: {
host: "localhost:3000",
"x-forwarded-host": "attacker.evil.com",
"x-twilio-signature": "invalid",
},
rawBody: postBody,
url: "http://localhost:3000/voice/webhook",
method: "POST",
},
authToken,
{ allowedHosts: ["myapp.ngrok.io", "webhook.example.com"] },
);
expect(result.ok).toBe(false);
// Attacker's host not in whitelist, falls back to Host header
expect(result.verificationUrl).toBe("https://localhost/voice/webhook");
});
it("trusts forwarding headers only from trusted proxy IPs", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
const webhookUrl = "https://proxy.example.com/voice/webhook";
const signature = twilioSignature({ authToken, url: webhookUrl, postBody });
const result = verifyTwilioWebhook(
{
headers: {
host: "localhost:3000",
"x-forwarded-proto": "https",
"x-forwarded-host": "proxy.example.com",
"x-twilio-signature": signature,
},
rawBody: postBody,
url: "http://localhost:3000/voice/webhook",
method: "POST",
remoteAddress: "203.0.113.10",
},
authToken,
{ trustForwardingHeaders: true, trustedProxyIPs: ["203.0.113.10"] },
);
expect(result.ok).toBe(true);
expect(result.verificationUrl).toBe(webhookUrl);
});
it("ignores forwarding headers when trustedProxyIPs are set but remote IP is missing", () => {
const authToken = "test-auth-token";
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
const result = verifyTwilioWebhook(
{
headers: {
host: "legitimate.example.com",
"x-forwarded-proto": "https",
"x-forwarded-host": "proxy.example.com",
"x-twilio-signature": "invalid",
},
rawBody: postBody,
url: "http://localhost:3000/voice/webhook",
method: "POST",
},
authToken,
{ trustForwardingHeaders: true, trustedProxyIPs: ["203.0.113.10"] },
);
expect(result.ok).toBe(false);
expect(result.verificationUrl).toBe("https://legitimate.example.com/voice/webhook");
});
});

View File

@@ -57,9 +57,119 @@ function timingSafeEqual(a: string, b: string): boolean {
return crypto.timingSafeEqual(bufA, bufB);
}
/**
* Configuration for secure URL reconstruction.
*/
export interface WebhookUrlOptions {
/**
* Whitelist of allowed hostnames. If provided, only these hosts will be
* accepted from forwarding headers. This prevents host header injection attacks.
*
* SECURITY: You must provide this OR set trustForwardingHeaders=true to use
* X-Forwarded-Host headers. Without either, forwarding headers are ignored.
*/
allowedHosts?: string[];
/**
* Explicitly trust X-Forwarded-* headers without a whitelist.
* WARNING: Only set this to true if you trust your proxy configuration
* and understand the security implications.
*
* @default false
*/
trustForwardingHeaders?: boolean;
/**
* List of trusted proxy IP addresses. X-Forwarded-* headers will only be
* trusted if the request comes from one of these IPs.
* Requires remoteIP to be set for validation.
*/
trustedProxyIPs?: string[];
/**
* The IP address of the incoming request (for proxy validation).
*/
remoteIP?: string;
}
/**
* Validate that a hostname matches RFC 1123 format.
* Prevents injection of malformed hostnames.
*/
function isValidHostname(hostname: string): boolean {
if (!hostname || hostname.length > 253) {
return false;
}
// RFC 1123 hostname: alphanumeric, hyphens, dots
// Also allow ngrok/tunnel subdomains
const hostnameRegex =
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
return hostnameRegex.test(hostname);
}
/**
* Safely extract hostname from a host header value.
* Handles IPv6 addresses and prevents injection via malformed values.
*/
function extractHostname(hostHeader: string): string | null {
if (!hostHeader) {
return null;
}
let hostname: string;
// Handle IPv6 addresses: [::1]:8080
if (hostHeader.startsWith("[")) {
const endBracket = hostHeader.indexOf("]");
if (endBracket === -1) {
return null; // Malformed IPv6
}
hostname = hostHeader.substring(1, endBracket);
return hostname.toLowerCase();
}
// Handle IPv4/domain with optional port
// Check for @ which could indicate user info injection attempt
if (hostHeader.includes("@")) {
return null; // Reject potential injection: attacker.com:80@legitimate.com
}
hostname = hostHeader.split(":")[0];
// Validate the extracted hostname
if (!isValidHostname(hostname)) {
return null;
}
return hostname.toLowerCase();
}
function extractHostnameFromHeader(headerValue: string): string | null {
const first = headerValue.split(",")[0]?.trim();
if (!first) {
return null;
}
return extractHostname(first);
}
function normalizeAllowedHosts(allowedHosts?: string[]): Set<string> | null {
if (!allowedHosts || allowedHosts.length === 0) {
return null;
}
const normalized = new Set<string>();
for (const host of allowedHosts) {
const extracted = extractHostname(host.trim());
if (extracted) {
normalized.add(extracted);
}
}
return normalized.size > 0 ? normalized : null;
}
/**
* Reconstruct the public webhook URL from request headers.
*
* SECURITY: This function validates host headers to prevent host header
* injection attacks. When using forwarding headers (X-Forwarded-Host, etc.),
* always provide allowedHosts to whitelist valid hostnames.
*
* When behind a reverse proxy (Tailscale, nginx, ngrok), the original URL
* used by Twilio differs from the local request URL. We use standard
* forwarding headers to reconstruct it.
@@ -70,17 +180,84 @@ function timingSafeEqual(a: string, b: string): boolean {
* 3. Ngrok-Forwarded-Host (ngrok specific)
* 4. Host header (direct connection)
*/
export function reconstructWebhookUrl(ctx: WebhookContext): string {
export function reconstructWebhookUrl(ctx: WebhookContext, options?: WebhookUrlOptions): string {
const { headers } = ctx;
const proto = getHeader(headers, "x-forwarded-proto") || "https";
// SECURITY: Only trust forwarding headers if explicitly configured.
// Either allowedHosts must be set (for whitelist validation) or
// trustForwardingHeaders must be true (explicit opt-in to trust).
const allowedHosts = normalizeAllowedHosts(options?.allowedHosts);
const hasAllowedHosts = allowedHosts !== null;
const explicitlyTrusted = options?.trustForwardingHeaders === true;
const forwardedHost =
getHeader(headers, "x-forwarded-host") ||
getHeader(headers, "x-original-host") ||
getHeader(headers, "ngrok-forwarded-host") ||
getHeader(headers, "host") ||
"";
// Also check trusted proxy IPs if configured
const trustedProxyIPs = options?.trustedProxyIPs?.filter(Boolean) ?? [];
const hasTrustedProxyIPs = trustedProxyIPs.length > 0;
const remoteIP = options?.remoteIP ?? ctx.remoteAddress;
const fromTrustedProxy =
!hasTrustedProxyIPs || (remoteIP ? trustedProxyIPs.includes(remoteIP) : false);
// Only trust forwarding headers if: (has whitelist OR explicitly trusted) AND from trusted proxy
const shouldTrustForwardingHeaders = (hasAllowedHosts || explicitlyTrusted) && fromTrustedProxy;
const isAllowedForwardedHost = (host: string): boolean => !allowedHosts || allowedHosts.has(host);
// Determine protocol - only trust X-Forwarded-Proto from trusted proxies
let proto = "https";
if (shouldTrustForwardingHeaders) {
const forwardedProto = getHeader(headers, "x-forwarded-proto");
if (forwardedProto === "http" || forwardedProto === "https") {
proto = forwardedProto;
}
}
// Determine host - with security validation
let host: string | null = null;
if (shouldTrustForwardingHeaders) {
// Try forwarding headers in priority order
const forwardingHeaders = ["x-forwarded-host", "x-original-host", "ngrok-forwarded-host"];
for (const headerName of forwardingHeaders) {
const headerValue = getHeader(headers, headerName);
if (headerValue) {
const extracted = extractHostnameFromHeader(headerValue);
if (extracted && isAllowedForwardedHost(extracted)) {
host = extracted;
break;
}
}
}
}
// Fallback to Host header if no valid forwarding header found
if (!host) {
const hostHeader = getHeader(headers, "host");
if (hostHeader) {
const extracted = extractHostnameFromHeader(hostHeader);
if (extracted) {
host = extracted;
}
}
}
// Last resort: try to extract from ctx.url
if (!host) {
try {
const parsed = new URL(ctx.url);
const extracted = extractHostname(parsed.host);
if (extracted) {
host = extracted;
}
} catch {
// URL parsing failed - use empty string (will result in invalid URL)
host = "";
}
}
if (!host) {
host = "";
}
// Extract path from the context URL (fallback to "/" on parse failure)
let path = "/";
@@ -91,15 +268,16 @@ export function reconstructWebhookUrl(ctx: WebhookContext): string {
// URL parsing failed
}
// Remove port from host (ngrok URLs don't have ports)
const host = forwardedHost.split(":")[0] || forwardedHost;
return `${proto}://${host}${path}`;
}
function buildTwilioVerificationUrl(ctx: WebhookContext, publicUrl?: string): string {
function buildTwilioVerificationUrl(
ctx: WebhookContext,
publicUrl?: string,
urlOptions?: WebhookUrlOptions,
): string {
if (!publicUrl) {
return reconstructWebhookUrl(ctx);
return reconstructWebhookUrl(ctx, urlOptions);
}
try {
@@ -154,9 +332,6 @@ export interface TwilioVerificationResult {
/**
* Verify Twilio webhook with full context and detailed result.
*
* Handles the special case of ngrok free tier where signature validation
* may fail due to URL discrepancies (ngrok adds interstitial page handling).
*/
export function verifyTwilioWebhook(
ctx: WebhookContext,
@@ -168,6 +343,26 @@ export function verifyTwilioWebhook(
allowNgrokFreeTierLoopbackBypass?: boolean;
/** Skip verification entirely (only for development) */
skipVerification?: boolean;
/**
* Whitelist of allowed hostnames for host header validation.
* Prevents host header injection attacks.
*/
allowedHosts?: string[];
/**
* Explicitly trust X-Forwarded-* headers without a whitelist.
* WARNING: Only enable if you trust your proxy configuration.
* @default false
*/
trustForwardingHeaders?: boolean;
/**
* List of trusted proxy IP addresses. X-Forwarded-* headers will only
* be trusted from these IPs.
*/
trustedProxyIPs?: string[];
/**
* The remote IP address of the request (for proxy validation).
*/
remoteIP?: string;
},
): TwilioVerificationResult {
// Allow skipping verification for development/testing
@@ -181,8 +376,16 @@ export function verifyTwilioWebhook(
return { ok: false, reason: "Missing X-Twilio-Signature header" };
}
const isLoopback = isLoopbackAddress(options?.remoteIP ?? ctx.remoteAddress);
const allowLoopbackForwarding = options?.allowNgrokFreeTierLoopbackBypass && isLoopback;
// Reconstruct the URL Twilio used
const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl);
const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl, {
allowedHosts: options?.allowedHosts,
trustForwardingHeaders: options?.trustForwardingHeaders || allowLoopbackForwarding,
trustedProxyIPs: options?.trustedProxyIPs,
remoteIP: options?.remoteIP,
});
// Parse the body as URL-encoded params
const params = new URLSearchParams(ctx.rawBody);
@@ -198,11 +401,7 @@ export function verifyTwilioWebhook(
const isNgrokFreeTier =
verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");
if (
isNgrokFreeTier &&
options?.allowNgrokFreeTierLoopbackBypass &&
isLoopbackAddress(ctx.remoteAddress)
) {
if (isNgrokFreeTier && options?.allowNgrokFreeTierLoopbackBypass && isLoopback) {
console.warn(
"[voice-call] Twilio signature validation failed (ngrok free tier compatibility, loopback only)",
);
@@ -384,6 +583,26 @@ export function verifyPlivoWebhook(
publicUrl?: string;
/** Skip verification entirely (only for development) */
skipVerification?: boolean;
/**
* Whitelist of allowed hostnames for host header validation.
* Prevents host header injection attacks.
*/
allowedHosts?: string[];
/**
* Explicitly trust X-Forwarded-* headers without a whitelist.
* WARNING: Only enable if you trust your proxy configuration.
* @default false
*/
trustForwardingHeaders?: boolean;
/**
* List of trusted proxy IP addresses. X-Forwarded-* headers will only
* be trusted from these IPs.
*/
trustedProxyIPs?: string[];
/**
* The remote IP address of the request (for proxy validation).
*/
remoteIP?: string;
},
): PlivoVerificationResult {
if (options?.skipVerification) {
@@ -395,7 +614,12 @@ export function verifyPlivoWebhook(
const signatureV2 = getHeader(ctx.headers, "x-plivo-signature-v2");
const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce");
const reconstructed = reconstructWebhookUrl(ctx);
const reconstructed = reconstructWebhookUrl(ctx, {
allowedHosts: options?.allowedHosts,
trustForwardingHeaders: options?.trustForwardingHeaders,
trustedProxyIPs: options?.trustedProxyIPs,
remoteIP: options?.remoteIP,
});
let verificationUrl = reconstructed;
if (options?.publicUrl) {
try {

View File

@@ -1,6 +1,6 @@
{
"name": "openclaw",
"version": "2026.2.2",
"version": "2026.2.3",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"keywords": [],
"license": "MIT",
@@ -32,7 +32,7 @@
"android:install": "cd apps/android && ./gradlew :app:installDebug",
"android:run": "cd apps/android && ./gradlew :app:installDebug && adb shell am start -n ai.openclaw.android/.MainActivity",
"android:test": "cd apps/android && ./gradlew :app:testDebugUnitTest",
"build": "pnpm canvas:a2ui:bundle && tsdown && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts",
"build": "pnpm canvas:a2ui:bundle && tsdown && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-compat.ts",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"check": "pnpm tsgo && pnpm lint && pnpm format",
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",

View File

@@ -9,6 +9,9 @@ set -euo pipefail
# - dist/OpenClaw-<version>.dmg
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_ROOT="$ROOT_DIR/apps/macos/.build"
PRODUCT="OpenClaw"
BUILD_CONFIG="${BUILD_CONFIG:-release}"
# Default to universal binary for distribution builds (supports both Apple Silicon and Intel Macs)
export BUILD_ARCHS="${BUILD_ARCHS:-all}"
@@ -25,8 +28,10 @@ VERSION=$(/usr/libexec/PlistBuddy -c "Print CFBundleShortVersionString" "$APP/Co
ZIP="$ROOT_DIR/dist/OpenClaw-$VERSION.zip"
DMG="$ROOT_DIR/dist/OpenClaw-$VERSION.dmg"
NOTARY_ZIP="$ROOT_DIR/dist/OpenClaw-$VERSION.notary.zip"
DSYM_ZIP="$ROOT_DIR/dist/OpenClaw-$VERSION.dSYM.zip"
SKIP_NOTARIZE="${SKIP_NOTARIZE:-0}"
NOTARIZE=1
SKIP_DSYM="${SKIP_DSYM:-0}"
if [[ "$SKIP_NOTARIZE" == "1" ]]; then
NOTARIZE=0
@@ -54,3 +59,31 @@ if [[ "$NOTARIZE" == "1" ]]; then
fi
"$ROOT_DIR/scripts/notarize-mac-artifact.sh" "$DMG"
fi
if [[ "$SKIP_DSYM" != "1" ]]; then
DSYM_ARM64="$(find "$BUILD_ROOT/arm64" -type d -path "*/$BUILD_CONFIG/$PRODUCT.dSYM" -print -quit)"
DSYM_X86="$(find "$BUILD_ROOT/x86_64" -type d -path "*/$BUILD_CONFIG/$PRODUCT.dSYM" -print -quit)"
if [[ -n "$DSYM_ARM64" || -n "$DSYM_X86" ]]; then
TMP_DSYM="$ROOT_DIR/dist/$PRODUCT.dSYM"
rm -rf "$TMP_DSYM"
if [[ -n "$DSYM_ARM64" && -n "$DSYM_X86" ]]; then
cp -R "$DSYM_ARM64" "$TMP_DSYM"
DWARF_OUT="$TMP_DSYM/Contents/Resources/DWARF/$PRODUCT"
DWARF_ARM="$DSYM_ARM64/Contents/Resources/DWARF/$PRODUCT"
DWARF_X86="$DSYM_X86/Contents/Resources/DWARF/$PRODUCT"
if [[ -f "$DWARF_ARM" && -f "$DWARF_X86" ]]; then
/usr/bin/lipo -create "$DWARF_ARM" "$DWARF_X86" -output "$DWARF_OUT"
else
echo "WARN: Missing DWARF binaries for dSYM merge (continuing)" >&2
fi
else
cp -R "${DSYM_ARM64:-$DSYM_X86}" "$TMP_DSYM"
fi
echo "🧩 dSYM: $DSYM_ZIP"
rm -f "$DSYM_ZIP"
ditto -c -k --keepParent "$TMP_DSYM" "$DSYM_ZIP"
rm -rf "$TMP_DSYM"
else
echo "WARN: dSYM not found; skipping zip (set SKIP_DSYM=1 to silence)" >&2
fi
fi

View File

@@ -0,0 +1,25 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const distDir = path.join(rootDir, "dist");
const cliDir = path.join(distDir, "cli");
const candidates = fs
.readdirSync(distDir)
.filter((entry) => entry.startsWith("daemon-cli-") && entry.endsWith(".js"));
if (candidates.length === 0) {
throw new Error("No daemon-cli bundle found in dist; cannot write legacy CLI shim.");
}
const target = candidates.toSorted()[0];
const relPath = `../${target}`;
const contents =
"// Legacy shim for pre-tsdown update-cli imports.\n" +
`export { registerDaemonCli, runDaemonInstall, runDaemonRestart, runDaemonStart, runDaemonStatus, runDaemonStop, runDaemonUninstall } from "${relPath}";\n`;
fs.mkdirSync(cliDir, { recursive: true });
fs.writeFileSync(path.join(cliDir, "daemon-cli.js"), contents);

View File

@@ -1,44 +1,131 @@
---
name: bluebubbles
description: Build or update the BlueBubbles external channel plugin for OpenClaw (extension package, REST send/probe, webhook inbound).
description: Use when you need to send or manage iMessages via BlueBubbles (recommended iMessage integration). Calls go through the generic message tool with channel="bluebubbles".
metadata: { "openclaw": { "emoji": "🫧", "requires": { "config": ["channels.bluebubbles"] } } }
---
# BlueBubbles plugin
# BlueBubbles Actions
Use this skill when working on the BlueBubbles channel plugin.
## Overview
## Layout
BlueBubbles is OpenClaws recommended iMessage integration. Use the `message` tool with `channel: "bluebubbles"` to send messages and manage iMessage conversations: send texts and attachments, react (tapbacks), edit/unsend, reply in threads, and manage group participants/names/icons.
- Extension package: `extensions/bluebubbles/` (entry: `index.ts`).
- Channel implementation: `extensions/bluebubbles/src/channel.ts`.
- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register via `api.registerHttpHandler`).
- REST helpers: `extensions/bluebubbles/src/send.ts` + `extensions/bluebubbles/src/probe.ts`.
- Runtime bridge: `extensions/bluebubbles/src/runtime.ts` (set via `api.runtime`).
- Catalog entry for onboarding: `src/channels/plugins/catalog.ts`.
## Inputs to collect
## Internal helpers (use these, not raw API calls)
- `target` (prefer `chat_guid:...`; also `+15551234567` in E.164 or `user@example.com`)
- `message` text for send/edit/reply
- `messageId` for react/edit/unsend/reply
- Attachment `path` for local files, or `buffer` + `filename` for base64
- `probeBlueBubbles` in `extensions/bluebubbles/src/probe.ts` for health checks.
- `sendMessageBlueBubbles` in `extensions/bluebubbles/src/send.ts` for text delivery.
- `resolveChatGuidForTarget` in `extensions/bluebubbles/src/send.ts` for chat lookup.
- `sendBlueBubblesReaction` in `extensions/bluebubbles/src/reactions.ts` for tapbacks.
- `sendBlueBubblesTyping` + `markBlueBubblesChatRead` in `extensions/bluebubbles/src/chat.ts`.
- `downloadBlueBubblesAttachment` in `extensions/bluebubbles/src/attachments.ts` for inbound media.
- `buildBlueBubblesApiUrl` + `blueBubblesFetchWithTimeout` in `extensions/bluebubbles/src/types.ts` for shared REST plumbing.
If the user is vague ("text my mom"), ask for the recipient handle or chat guid and the exact message content.
## Webhooks
## Actions
- BlueBubbles posts JSON to the gateway HTTP server.
- Normalize sender/chat IDs defensively (payloads vary by version).
- Skip messages marked as from self.
- Route into core reply pipeline via the plugin runtime (`api.runtime`) and `openclaw/plugin-sdk` helpers.
- For attachments/stickers, use `<media:...>` placeholders when text is empty and attach media paths via `MediaUrl(s)` in the inbound context.
### Send a message
## Config (core)
```json
{
"action": "send",
"channel": "bluebubbles",
"target": "+15551234567",
"message": "hello from OpenClaw"
}
```
- `channels.bluebubbles.serverUrl` (base URL), `channels.bluebubbles.password`, `channels.bluebubbles.webhookPath`.
- Action gating: `channels.bluebubbles.actions.reactions` (default true).
### React (tapback)
## Message tool notes
```json
{
"action": "react",
"channel": "bluebubbles",
"target": "+15551234567",
"messageId": "<message-guid>",
"emoji": "❤️"
}
```
- **Reactions:** The `react` action requires a `target` (phone number or chat identifier) in addition to `messageId`. Example: `action=react target=+15551234567 messageId=ABC123 emoji=❤️`
### Remove a reaction
```json
{
"action": "react",
"channel": "bluebubbles",
"target": "+15551234567",
"messageId": "<message-guid>",
"emoji": "❤️",
"remove": true
}
```
### Edit a previously sent message
```json
{
"action": "edit",
"channel": "bluebubbles",
"target": "+15551234567",
"messageId": "<message-guid>",
"message": "updated text"
}
```
### Unsend a message
```json
{
"action": "unsend",
"channel": "bluebubbles",
"target": "+15551234567",
"messageId": "<message-guid>"
}
```
### Reply to a specific message
```json
{
"action": "reply",
"channel": "bluebubbles",
"target": "+15551234567",
"replyTo": "<message-guid>",
"message": "replying to that"
}
```
### Send an attachment
```json
{
"action": "sendAttachment",
"channel": "bluebubbles",
"target": "+15551234567",
"path": "/tmp/photo.jpg",
"caption": "here you go"
}
```
### Send with an iMessage effect
```json
{
"action": "sendWithEffect",
"channel": "bluebubbles",
"target": "+15551234567",
"message": "big news",
"effect": "balloons"
}
```
## Notes
- Requires gateway config `channels.bluebubbles` (serverUrl/password/webhookPath).
- Prefer `chat_guid` targets when you have them (especially for group chats).
- BlueBubbles supports rich actions, but some are macOS-version dependent (for example, edit may be broken on macOS 26 Tahoe).
- The gateway may expose both short and full message ids; full ids are more durable across restarts.
- Developer reference for the underlying plugin lives in `extensions/bluebubbles/README.md`.
## Ideas to try
- React with a tapback to acknowledge a request.
- Reply in-thread when a user references a specific message.
- Send a file attachment with a short caption.

View File

@@ -1,6 +1,6 @@
---
name: discord
description: Use when you need to control Discord from OpenClaw via the discord tool: send messages, react, post or upload stickers, upload emojis, run polls, manage threads/pins/search, create/edit/delete channels and categories, fetch permissions or member/role/channel info, or handle moderation actions in Discord DMs or channels.
description: Use when you need to control Discord from OpenClaw via the discord tool: send messages, react, post or upload stickers, upload emojis, run polls, manage threads/pins/search, create/edit/delete channels and categories, fetch permissions or member/role/channel info, set bot presence/activity, or handle moderation actions in Discord DMs or channels.
metadata: {"openclaw":{"emoji":"🎮","requires":{"config":["channels.discord"]}}}
---
@@ -139,6 +139,7 @@ Use `discord.actions.*` to disable action groups:
- `roles` (role add/remove, default `false`)
- `channels` (channel/category create/edit/delete/move, default `false`)
- `moderation` (timeout/kick/ban, default `false`)
- `presence` (bot status/activity, default `false`)
### Read recent messages
@@ -432,6 +433,101 @@ Create, edit, delete, and move channels and categories. Enable via `discord.acti
}
```
### Bot presence/activity (disabled by default)
Set the bot's online status and activity. Enable via `discord.actions.presence: true`.
Discord bots can only set `name`, `state`, `type`, and `url` on an activity. Other Activity fields (details, emoji, assets) are accepted by the gateway but silently ignored by Discord for bots.
**How fields render by activity type:**
- **playing, streaming, listening, watching, competing**: `activityName` is shown in the sidebar under the bot's name (e.g. "**with fire**" for type "playing" and name "with fire"). `activityState` is shown in the profile flyout.
- **custom**: `activityName` is ignored. Only `activityState` is displayed as the status text in the sidebar.
- **streaming**: `activityUrl` may be displayed or embedded by the client.
**Set playing status:**
```json
{
"action": "setPresence",
"activityType": "playing",
"activityName": "with fire"
}
```
Result in sidebar: "**with fire**". Flyout shows: "Playing: with fire"
**With state (shown in flyout):**
```json
{
"action": "setPresence",
"activityType": "playing",
"activityName": "My Game",
"activityState": "In the lobby"
}
```
Result in sidebar: "**My Game**". Flyout shows: "Playing: My Game (newline) In the lobby".
**Set streaming (optional URL, may not render for bots):**
```json
{
"action": "setPresence",
"activityType": "streaming",
"activityName": "Live coding",
"activityUrl": "https://twitch.tv/example"
}
```
**Set listening/watching:**
```json
{
"action": "setPresence",
"activityType": "listening",
"activityName": "Spotify"
}
```
```json
{
"action": "setPresence",
"activityType": "watching",
"activityName": "the logs"
}
```
**Set a custom status (text in sidebar):**
```json
{
"action": "setPresence",
"activityType": "custom",
"activityState": "Vibing"
}
```
Result in sidebar: "Vibing". Note: `activityName` is ignored for custom type.
**Set bot status only (no activity/clear status):**
```json
{
"action": "setPresence",
"status": "dnd"
}
```
**Parameters:**
- `activityType`: `playing`, `streaming`, `listening`, `watching`, `competing`, `custom`
- `activityName`: text shown in the sidebar for non-custom types (ignored for `custom`)
- `activityUrl`: Twitch or YouTube URL for streaming type (optional; may not render for bots)
- `activityState`: for `custom` this is the status text; for other types it shows in the profile flyout
- `status`: `online` (default), `dnd`, `idle`, `invisible`
## Discord Writing Style Guide
**Keep it conversational!** Discord is a chat platform, not documentation.

View File

@@ -23,24 +23,52 @@ metadata:
}
---
# imsg
# imsg Actions
## Overview
Use `imsg` to read and send Messages.app iMessage/SMS on macOS.
Requirements
Requirements: Messages.app signed in, Full Disk Access for your terminal, and Automation permission to control Messages.app for sending.
- Messages.app signed in
- Full Disk Access for your terminal
- Automation permission to control Messages.app (for sending)
## Inputs to collect
Common commands
- Recipient handle (phone/email) for `send`
- `chatId` for history/watch (from `imsg chats --limit 10 --json`)
- `text` and optional `file` path for sends
- List chats: `imsg chats --limit 10 --json`
- History: `imsg history --chat-id 1 --limit 20 --attachments --json`
- Watch: `imsg watch --chat-id 1 --attachments`
- Send: `imsg send --to "+14155551212" --text "hi" --file /path/pic.jpg`
## Actions
Notes
### List chats
```bash
imsg chats --limit 10 --json
```
### Fetch chat history
```bash
imsg history --chat-id 1 --limit 20 --attachments --json
```
### Watch a chat
```bash
imsg watch --chat-id 1 --attachments
```
### Send a message
```bash
imsg send --to "+14155551212" --text "hi" --file /path/pic.jpg
```
## Notes
- `--service imessage|sms|auto` controls delivery.
- Confirm recipient + message before sending.
## Ideas to try
- Use `imsg chats --limit 10 --json` to discover chat ids.
- Watch a high-signal chat to stream incoming messages.

View File

@@ -50,6 +50,15 @@ To monitor:
- Prefer literal sends: `tmux -S "$SOCKET" send-keys -t target -l -- "$cmd"`.
- Control keys: `tmux -S "$SOCKET" send-keys -t target C-c`.
- For interactive TUI apps like Claude Code/Codex, this guidance covers **how to send commands**.
Do **not** append `Enter` in the same `send-keys`. These apps may treat a fast text+Enter
sequence as paste/multi-line input and not submit; this is timing-dependent. Send text and
`Enter` as separate commands with a small delay (tune per environment; increase if needed,
or use `sleep 1` if sub-second sleeps aren't supported):
```bash
tmux -S "$SOCKET" send-keys -t target -l -- "$cmd" && sleep 0.1 && tmux -S "$SOCKET" send-keys -t target Enter
```
## Watching output
@@ -82,6 +91,9 @@ done
tmux -S "$SOCKET" send-keys -t agent-1 "cd /tmp/project1 && codex --yolo 'Fix bug X'" Enter
tmux -S "$SOCKET" send-keys -t agent-2 "cd /tmp/project2 && codex --yolo 'Fix bug Y'" Enter
# When sending prompts to Claude Code/Codex TUI, split text + Enter with a delay
tmux -S "$SOCKET" send-keys -t agent-1 -l -- "Please make a small edit to README.md." && sleep 0.1 && tmux -S "$SOCKET" send-keys -t agent-1 Enter
# Poll for completion (check if prompt returned)
for sess in agent-1 agent-2; do
if tmux -S "$SOCKET" capture-pane -p -t "$sess" -S -3 | grep -q ""; then

View File

@@ -53,6 +53,10 @@ export function createOpenClawTools(options?: {
modelHasVision?: boolean;
/** Explicit agent ID override for cron/hook sessions. */
requesterAgentIdOverride?: string;
/** Require explicit message targets (no implicit last-route sends). */
requireExplicitMessageTarget?: boolean;
/** If true, omit the message tool from the tool list. */
disableMessageTool?: boolean;
}): AnyAgentTool[] {
const imageTool = options?.agentDir?.trim()
? createImageTool({
@@ -70,6 +74,20 @@ export function createOpenClawTools(options?: {
config: options?.config,
sandboxed: options?.sandboxed,
});
const messageTool = options?.disableMessageTool
? null
: createMessageTool({
agentAccountId: options?.agentAccountId,
agentSessionKey: options?.agentSessionKey,
config: options?.config,
currentChannelId: options?.currentChannelId,
currentChannelProvider: options?.agentChannel,
currentThreadTs: options?.currentThreadTs,
replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef,
sandboxRoot: options?.sandboxRoot,
requireExplicitTarget: options?.requireExplicitMessageTarget,
});
const tools: AnyAgentTool[] = [
createBrowserTool({
sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl,
@@ -83,17 +101,7 @@ export function createOpenClawTools(options?: {
createCronTool({
agentSessionKey: options?.agentSessionKey,
}),
createMessageTool({
agentAccountId: options?.agentAccountId,
agentSessionKey: options?.agentSessionKey,
config: options?.config,
currentChannelId: options?.currentChannelId,
currentChannelProvider: options?.agentChannel,
currentThreadTs: options?.currentThreadTs,
replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef,
sandboxRoot: options?.sandboxRoot,
}),
...(messageTool ? [messageTool] : []),
createTtsTool({
agentChannel: options?.agentChannel,
config: options?.config,

View File

@@ -238,6 +238,9 @@ export async function runEmbeddedAttempt(
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
modelHasVision,
requireExplicitMessageTarget:
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
disableMessageTool: params.disableMessageTool,
});
const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider: params.provider });
logToolSchemasForGoogle({ tools, provider: params.provider });

View File

@@ -47,6 +47,10 @@ export type RunEmbeddedPiAgentParams = {
replyToMode?: "off" | "first" | "all";
/** Mutable ref to track if a reply was sent (for "first" mode). */
hasRepliedRef?: { value: boolean };
/** Require explicit message tool targets (no implicit last-route sends). */
requireExplicitMessageTarget?: boolean;
/** If true, omit the message tool from the tool list. */
disableMessageTool?: boolean;
sessionFile: string;
workspaceDir: string;
agentDir?: string;

View File

@@ -78,6 +78,10 @@ export type EmbeddedRunAttemptParams = {
onReasoningStream?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onToolResult?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
/** Require explicit message tool targets (no implicit last-route sends). */
requireExplicitMessageTarget?: boolean;
/** If true, omit the message tool from the tool list. */
disableMessageTool?: boolean;
extraSystemPrompt?: string;
streamParams?: AgentStreamParams;
ownerNumbers?: string[];

View File

@@ -157,6 +157,10 @@ export function createOpenClawCodingTools(options?: {
hasRepliedRef?: { value: boolean };
/** If true, the model has native vision capability */
modelHasVision?: boolean;
/** Require explicit message targets (no implicit last-route sends). */
requireExplicitMessageTarget?: boolean;
/** If true, omit the message tool from the tool list. */
disableMessageTool?: boolean;
}): AnyAgentTool[] {
const execToolName = "exec";
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
@@ -348,6 +352,8 @@ export function createOpenClawCodingTools(options?: {
replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef,
modelHasVision: options?.modelHasVision,
requireExplicitMessageTarget: options?.requireExplicitMessageTarget,
disableMessageTool: options?.disableMessageTool,
requesterAgentIdOverride: agentId,
}),
];

View File

@@ -323,10 +323,10 @@ export function buildSubagentSystemPrompt(params: {
"",
"## What You DON'T Do",
"- NO user conversations (that's main agent's job)",
"- NO external messages (email, tweets, etc.) unless explicitly tasked",
"- NO external messages (email, tweets, etc.) unless explicitly tasked with a specific recipient/channel",
"- NO cron jobs or persistent state",
"- NO pretending to be the main agent",
"- NO using the `message` tool directly",
"- Only use the `message` tool when explicitly instructed to contact a specific external recipient; otherwise return plain text and let the main agent deliver it",
"",
"## Session Context",
params.label ? `- Label: ${params.label}` : undefined,

View File

@@ -82,7 +82,9 @@ describe("cron tool", () => {
expect(call.method).toBe("cron.add");
expect(call.params).toEqual({
name: "wake-up",
schedule: { kind: "at", atMs: 123 },
enabled: true,
deleteAfterRun: true,
schedule: { kind: "at", at: new Date(123).toISOString() },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
@@ -95,7 +97,7 @@ describe("cron tool", () => {
action: "add",
job: {
name: "wake-up",
schedule: { atMs: 123 },
schedule: { at: new Date(123).toISOString() },
agentId: null,
},
});
@@ -126,7 +128,7 @@ describe("cron tool", () => {
contextMessages: 3,
job: {
name: "reminder",
schedule: { atMs: 123 },
schedule: { at: new Date(123).toISOString() },
payload: { kind: "systemEvent", text: "Reminder: the thing." },
},
});
@@ -163,7 +165,7 @@ describe("cron tool", () => {
contextMessages: 20,
job: {
name: "reminder",
schedule: { atMs: 123 },
schedule: { at: new Date(123).toISOString() },
payload: { kind: "systemEvent", text: "Reminder: the thing." },
},
});
@@ -194,7 +196,7 @@ describe("cron tool", () => {
action: "add",
job: {
name: "reminder",
schedule: { atMs: 123 },
schedule: { at: new Date(123).toISOString() },
payload: { text: "Reminder: the thing." },
},
});
@@ -218,7 +220,7 @@ describe("cron tool", () => {
action: "add",
job: {
name: "reminder",
schedule: { atMs: 123 },
schedule: { at: new Date(123).toISOString() },
agentId: null,
payload: { kind: "systemEvent", text: "Reminder: the thing." },
},

View File

@@ -174,27 +174,36 @@ JOB SCHEMA (for add action):
"name": "string (optional)",
"schedule": { ... }, // Required: when to run
"payload": { ... }, // Required: what to execute
"delivery": { ... }, // Optional: announce summary (isolated only)
"sessionTarget": "main" | "isolated", // Required
"enabled": true | false // Optional, default true
}
SCHEDULE TYPES (schedule.kind):
- "at": One-shot at absolute time
{ "kind": "at", "atMs": <unix-ms-timestamp> }
{ "kind": "at", "at": "<ISO-8601 timestamp>" }
- "every": Recurring interval
{ "kind": "every", "everyMs": <interval-ms>, "anchorMs": <optional-start-ms> }
- "cron": Cron expression
{ "kind": "cron", "expr": "<cron-expression>", "tz": "<optional-timezone>" }
ISO timestamps without an explicit timezone are treated as UTC.
PAYLOAD TYPES (payload.kind):
- "systemEvent": Injects text as system event into session
{ "kind": "systemEvent", "text": "<message>" }
- "agentTurn": Runs agent with message (isolated sessions only)
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional>, "deliver": <optional-bool>, "channel": "<optional>", "to": "<optional>", "bestEffortDeliver": <optional-bool> }
{ "kind": "agentTurn", "message": "<prompt>", "model": "<optional>", "thinking": "<optional>", "timeoutSeconds": <optional> }
DELIVERY (isolated-only, top-level):
{ "mode": "none|announce", "channel": "<optional>", "to": "<optional>", "bestEffort": <optional-bool> }
- Default for isolated agentTurn jobs (when delivery omitted): "announce"
- If the task needs to send to a specific chat/recipient, set delivery.channel/to here; do not call messaging tools inside the run.
CRITICAL CONSTRAINTS:
- sessionTarget="main" REQUIRES payload.kind="systemEvent"
- sessionTarget="isolated" REQUIRES payload.kind="agentTurn"
Default: prefer isolated agentTurn jobs unless the user explicitly wants a main-session system event.
WAKE MODES (for wake action):
- "next-heartbeat" (default): Wake on next heartbeat
@@ -208,7 +217,7 @@ Use jobId as the canonical identifier; id is accepted for compatibility. Use con
const gatewayOpts: GatewayCallOptions = {
gatewayUrl: readStringParam(params, "gatewayUrl", { trim: false }),
gatewayToken: readStringParam(params, "gatewayToken", { trim: false }),
timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : undefined,
timeoutMs: typeof params.timeoutMs === "number" ? params.timeoutMs : 60_000,
};
switch (action) {

View File

@@ -0,0 +1,210 @@
import type { GatewayPlugin } from "@buape/carbon/gateway";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DiscordActionConfig } from "../../config/config.js";
import type { ActionGate } from "./common.js";
import { clearGateways, registerGateway } from "../../discord/monitor/gateway-registry.js";
import { handleDiscordPresenceAction } from "./discord-actions-presence.js";
const mockUpdatePresence = vi.fn();
function createMockGateway(connected = true): GatewayPlugin {
return { isConnected: connected, updatePresence: mockUpdatePresence } as unknown as GatewayPlugin;
}
const presenceEnabled: ActionGate<DiscordActionConfig> = (key) => key === "presence";
const presenceDisabled: ActionGate<DiscordActionConfig> = () => false;
describe("handleDiscordPresenceAction", () => {
beforeEach(() => {
mockUpdatePresence.mockClear();
clearGateways();
registerGateway(undefined, createMockGateway());
});
it("sets playing activity", async () => {
const result = await handleDiscordPresenceAction(
"setPresence",
{ activityType: "playing", activityName: "with fire", status: "online" },
presenceEnabled,
);
expect(mockUpdatePresence).toHaveBeenCalledWith({
since: null,
activities: [{ name: "with fire", type: 0 }],
status: "online",
afk: false,
});
const payload = JSON.parse(result.content[0].text ?? "");
expect(payload.ok).toBe(true);
expect(payload.activities[0]).toEqual({ type: 0, name: "with fire" });
});
it("sets streaming activity with optional URL", async () => {
await handleDiscordPresenceAction(
"setPresence",
{
activityType: "streaming",
activityName: "My Stream",
activityUrl: "https://twitch.tv/example",
},
presenceEnabled,
);
expect(mockUpdatePresence).toHaveBeenCalledWith({
since: null,
activities: [{ name: "My Stream", type: 1, url: "https://twitch.tv/example" }],
status: "online",
afk: false,
});
});
it("allows streaming without URL", async () => {
await handleDiscordPresenceAction(
"setPresence",
{ activityType: "streaming", activityName: "My Stream" },
presenceEnabled,
);
expect(mockUpdatePresence).toHaveBeenCalledWith({
since: null,
activities: [{ name: "My Stream", type: 1 }],
status: "online",
afk: false,
});
});
it("sets listening activity", async () => {
await handleDiscordPresenceAction(
"setPresence",
{ activityType: "listening", activityName: "Spotify" },
presenceEnabled,
);
expect(mockUpdatePresence).toHaveBeenCalledWith(
expect.objectContaining({
activities: [{ name: "Spotify", type: 2 }],
}),
);
});
it("sets watching activity", async () => {
await handleDiscordPresenceAction(
"setPresence",
{ activityType: "watching", activityName: "you" },
presenceEnabled,
);
expect(mockUpdatePresence).toHaveBeenCalledWith(
expect.objectContaining({
activities: [{ name: "you", type: 3 }],
}),
);
});
it("sets custom activity using state", async () => {
await handleDiscordPresenceAction(
"setPresence",
{ activityType: "custom", activityState: "Vibing" },
presenceEnabled,
);
expect(mockUpdatePresence).toHaveBeenCalledWith({
since: null,
activities: [{ name: "", type: 4, state: "Vibing" }],
status: "online",
afk: false,
});
});
it("includes activityState", async () => {
await handleDiscordPresenceAction(
"setPresence",
{ activityType: "playing", activityName: "My Game", activityState: "In the lobby" },
presenceEnabled,
);
expect(mockUpdatePresence).toHaveBeenCalledWith({
since: null,
activities: [{ name: "My Game", type: 0, state: "In the lobby" }],
status: "online",
afk: false,
});
});
it("sets status-only without activity", async () => {
await handleDiscordPresenceAction("setPresence", { status: "idle" }, presenceEnabled);
expect(mockUpdatePresence).toHaveBeenCalledWith({
since: null,
activities: [],
status: "idle",
afk: false,
});
});
it("defaults status to online", async () => {
await handleDiscordPresenceAction(
"setPresence",
{ activityType: "playing", activityName: "test" },
presenceEnabled,
);
expect(mockUpdatePresence).toHaveBeenCalledWith(expect.objectContaining({ status: "online" }));
});
it("rejects invalid status", async () => {
await expect(
handleDiscordPresenceAction("setPresence", { status: "offline" }, presenceEnabled),
).rejects.toThrow(/Invalid status/);
});
it("rejects invalid activity type", async () => {
await expect(
handleDiscordPresenceAction("setPresence", { activityType: "invalid" }, presenceEnabled),
).rejects.toThrow(/Invalid activityType/);
});
it("respects presence gating", async () => {
await expect(
handleDiscordPresenceAction("setPresence", { status: "online" }, presenceDisabled),
).rejects.toThrow(/disabled/);
});
it("errors when gateway is not registered", async () => {
clearGateways();
await expect(
handleDiscordPresenceAction("setPresence", { status: "dnd" }, presenceEnabled),
).rejects.toThrow(/not available/);
});
it("errors when gateway is not connected", async () => {
clearGateways();
registerGateway(undefined, createMockGateway(false));
await expect(
handleDiscordPresenceAction("setPresence", { status: "dnd" }, presenceEnabled),
).rejects.toThrow(/not connected/);
});
it("uses accountId to resolve gateway", async () => {
const accountGateway = createMockGateway();
registerGateway("my-account", accountGateway);
await handleDiscordPresenceAction(
"setPresence",
{ accountId: "my-account", activityType: "playing", activityName: "test" },
presenceEnabled,
);
expect(mockUpdatePresence).toHaveBeenCalled();
});
it("defaults activity name to empty string when only type is provided", async () => {
await handleDiscordPresenceAction("setPresence", { activityType: "playing" }, presenceEnabled);
expect(mockUpdatePresence).toHaveBeenCalledWith(
expect.objectContaining({
activities: [{ name: "", type: 0 }],
}),
);
});
it("requires activityType when activityName is provided", async () => {
await expect(
handleDiscordPresenceAction("setPresence", { activityName: "My Game" }, presenceEnabled),
).rejects.toThrow(/activityType is required/);
});
it("rejects unknown presence actions", async () => {
await expect(handleDiscordPresenceAction("unknownAction", {}, presenceEnabled)).rejects.toThrow(
/Unknown presence action/,
);
});
});

View File

@@ -0,0 +1,111 @@
import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { DiscordActionConfig } from "../../config/config.js";
import { getGateway } from "../../discord/monitor/gateway-registry.js";
import { type ActionGate, jsonResult, readStringParam } from "./common.js";
const ACTIVITY_TYPE_MAP: Record<string, number> = {
playing: 0,
streaming: 1,
listening: 2,
watching: 3,
custom: 4,
competing: 5,
};
const VALID_STATUSES = new Set(["online", "dnd", "idle", "invisible"]);
export async function handleDiscordPresenceAction(
action: string,
params: Record<string, unknown>,
isActionEnabled: ActionGate<DiscordActionConfig>,
): Promise<AgentToolResult<unknown>> {
if (action !== "setPresence") {
throw new Error(`Unknown presence action: ${action}`);
}
if (!isActionEnabled("presence", false)) {
throw new Error("Discord presence changes are disabled.");
}
const accountId = readStringParam(params, "accountId");
const gateway = getGateway(accountId);
if (!gateway) {
throw new Error(
`Discord gateway not available${accountId ? ` for account "${accountId}"` : ""}. The bot may not be connected.`,
);
}
if (!gateway.isConnected) {
throw new Error(
`Discord gateway is not connected${accountId ? ` for account "${accountId}"` : ""}.`,
);
}
const statusRaw = readStringParam(params, "status") ?? "online";
if (!VALID_STATUSES.has(statusRaw)) {
throw new Error(
`Invalid status "${statusRaw}". Must be one of: ${[...VALID_STATUSES].join(", ")}`,
);
}
const status = statusRaw as UpdatePresenceData["status"];
const activityTypeRaw = readStringParam(params, "activityType");
const activityName = readStringParam(params, "activityName");
const activities: Activity[] = [];
if (activityTypeRaw || activityName) {
if (!activityTypeRaw) {
throw new Error(
"activityType is required when activityName is provided. " +
`Valid types: ${Object.keys(ACTIVITY_TYPE_MAP).join(", ")}`,
);
}
const typeNum = ACTIVITY_TYPE_MAP[activityTypeRaw.toLowerCase()];
if (typeNum === undefined) {
throw new Error(
`Invalid activityType "${activityTypeRaw}". Must be one of: ${Object.keys(ACTIVITY_TYPE_MAP).join(", ")}`,
);
}
const activity: Activity = {
name: activityName ?? "",
type: typeNum,
};
// Streaming URL (Twitch/YouTube). May not render for bots but is the correct payload shape.
if (typeNum === 1) {
const url = readStringParam(params, "activityUrl");
if (url) {
activity.url = url;
}
}
const state = readStringParam(params, "activityState");
if (state) {
activity.state = state;
}
activities.push(activity);
}
const presenceData: UpdatePresenceData = {
since: null,
activities,
status,
afk: false,
};
gateway.updatePresence(presenceData);
return jsonResult({
ok: true,
status,
activities: activities.map((a) => ({
type: a.type,
name: a.name,
...(a.url ? { url: a.url } : {}),
...(a.state ? { state: a.state } : {}),
})),
});
}

View File

@@ -4,6 +4,7 @@ import { createActionGate, readStringParam } from "./common.js";
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
import { handleDiscordPresenceAction } from "./discord-actions-presence.js";
const messagingActions = new Set([
"react",
@@ -51,6 +52,8 @@ const guildActions = new Set([
const moderationActions = new Set(["timeout", "kick", "ban"]);
const presenceActions = new Set(["setPresence"]);
export async function handleDiscordAction(
params: Record<string, unknown>,
cfg: OpenClawConfig,
@@ -67,5 +70,8 @@ export async function handleDiscordAction(
if (moderationActions.has(action)) {
return await handleDiscordModerationAction(action, params, isActionEnabled);
}
if (presenceActions.has(action)) {
return await handleDiscordPresenceAction(action, params, isActionEnabled);
}
throw new Error(`Unknown action: ${action}`);
}

View File

@@ -22,7 +22,7 @@ export function resolveGatewayOptions(opts?: GatewayCallOptions) {
const timeoutMs =
typeof opts?.timeoutMs === "number" && Number.isFinite(opts.timeoutMs)
? Math.max(1, Math.floor(opts.timeoutMs))
: 10_000;
: 30_000;
return { url, token, timeoutMs };
}

View File

@@ -24,6 +24,18 @@ import { channelTargetSchema, channelTargetsSchema, stringEnum } from "../schema
import { jsonResult, readNumberParam, readStringParam } from "./common.js";
const AllMessageActions = CHANNEL_MESSAGE_ACTION_NAMES;
const EXPLICIT_TARGET_ACTIONS = new Set<ChannelMessageActionName>([
"send",
"sendWithEffect",
"sendAttachment",
"reply",
"thread-reply",
"broadcast",
]);
function actionNeedsExplicitTarget(action: ChannelMessageActionName): boolean {
return EXPLICIT_TARGET_ACTIONS.has(action);
}
function buildRoutingSchema() {
return {
channel: Type.Optional(Type.String()),
@@ -193,6 +205,36 @@ function buildGatewaySchema() {
};
}
function buildPresenceSchema() {
return {
activityType: Type.Optional(
Type.String({
description: "Activity type: playing, streaming, listening, watching, competing, custom.",
}),
),
activityName: Type.Optional(
Type.String({
description: "Activity name shown in sidebar (e.g. 'with fire'). Ignored for custom type.",
}),
),
activityUrl: Type.Optional(
Type.String({
description:
"Streaming URL (Twitch or YouTube). Only used with streaming type; may not render for bots.",
}),
),
activityState: Type.Optional(
Type.String({
description:
"State text. For custom type this is the status text; for others it shows in the flyout.",
}),
),
status: Type.Optional(
Type.String({ description: "Bot status: online, dnd, idle, invisible." }),
),
};
}
function buildChannelManagementSchema() {
return {
name: Type.Optional(Type.String()),
@@ -225,6 +267,7 @@ function buildMessageToolSchemaProps(options: { includeButtons: boolean; include
...buildModerationSchema(),
...buildGatewaySchema(),
...buildChannelManagementSchema(),
...buildPresenceSchema(),
};
}
@@ -254,6 +297,7 @@ type MessageToolOptions = {
replyToMode?: "off" | "first" | "all";
hasRepliedRef?: { value: boolean };
sandboxRoot?: string;
requireExplicitTarget?: boolean;
};
function buildMessageToolSchema(cfg: OpenClawConfig) {
@@ -363,6 +407,20 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
const action = readStringParam(params, "action", {
required: true,
}) as ChannelMessageActionName;
const requireExplicitTarget = options?.requireExplicitTarget === true;
if (requireExplicitTarget && actionNeedsExplicitTarget(action)) {
const explicitTarget =
(typeof params.target === "string" && params.target.trim().length > 0) ||
(typeof params.to === "string" && params.to.trim().length > 0) ||
(typeof params.channelId === "string" && params.channelId.trim().length > 0) ||
(Array.isArray(params.targets) &&
params.targets.some((value) => typeof value === "string" && value.trim().length > 0));
if (!explicitTarget) {
throw new Error(
"Explicit message target required for this run. Provide target/targets (and channel when needed).",
);
}
}
// Validate file paths against sandbox root to prevent host file access.
const sandboxRoot = options?.sandboxRoot;

View File

@@ -1,22 +1,6 @@
import { Type } from "@sinclair/typebox";
import type { OpenClawConfig } from "../../config/config.js";
import type { AnyAgentTool } from "./common.js";
import { resolveAgentDir } from "../../agents/agent-scope.js";
import {
ensureAuthProfileStore,
resolveAuthProfileDisplayLabel,
resolveAuthProfileOrder,
} from "../../agents/auth-profiles.js";
import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js";
import { loadModelCatalog } from "../../agents/model-catalog.js";
import {
buildAllowedModelSet,
buildModelAliasIndex,
modelKey,
normalizeProviderId,
resolveDefaultModelForAgent,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { normalizeGroupActivation } from "../../auto-reply/group-activation.js";
import { getFollowupQueueDepth, resolveQueueSettings } from "../../auto-reply/reply/queue.js";
import { buildStatusMessage } from "../../auto-reply/status.js";
@@ -39,7 +23,23 @@ import {
resolveAgentIdFromSessionKey,
} from "../../routing/session-key.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import { resolveAgentDir } from "../agent-scope.js";
import {
ensureAuthProfileStore,
resolveAuthProfileDisplayLabel,
resolveAuthProfileOrder,
} from "../auth-profiles.js";
import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js";
import { getCustomProviderApiKey, resolveEnvApiKey } from "../model-auth.js";
import { loadModelCatalog } from "../model-catalog.js";
import {
buildAllowedModelSet,
buildModelAliasIndex,
modelKey,
normalizeProviderId,
resolveDefaultModelForAgent,
resolveModelRefFromString,
} from "../model-selection.js";
import { readStringParam } from "./common.js";
import {
shouldResolveSessionIdInput,

View File

@@ -95,6 +95,17 @@ function resolveFetchReadabilityEnabled(fetch?: WebFetchConfig): boolean {
return true;
}
function resolveFetchMaxCharsCap(fetch?: WebFetchConfig): number {
const raw =
fetch && "maxCharsCap" in fetch && typeof fetch.maxCharsCap === "number"
? fetch.maxCharsCap
: undefined;
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return DEFAULT_FETCH_MAX_CHARS;
}
return Math.max(100, Math.floor(raw));
}
function resolveFirecrawlConfig(fetch?: WebFetchConfig): FirecrawlFetchConfig {
if (!fetch || typeof fetch !== "object") {
return undefined;
@@ -160,9 +171,10 @@ function resolveFirecrawlMaxAgeMsOrDefault(firecrawl?: FirecrawlFetchConfig): nu
return DEFAULT_FIRECRAWL_MAX_AGE_MS;
}
function resolveMaxChars(value: unknown, fallback: number): number {
function resolveMaxChars(value: unknown, fallback: number, cap: number): number {
const parsed = typeof value === "number" && Number.isFinite(value) ? value : fallback;
return Math.max(100, Math.floor(parsed));
const clamped = Math.max(100, Math.floor(parsed));
return Math.min(clamped, cap);
}
function resolveMaxRedirects(value: unknown, fallback: number): number {
@@ -647,10 +659,15 @@ export function createWebFetchTool(options?: {
const url = readStringParam(params, "url", { required: true });
const extractMode = readStringParam(params, "extractMode") === "text" ? "text" : "markdown";
const maxChars = readNumberParam(params, "maxChars", { integer: true });
const maxCharsCap = resolveFetchMaxCharsCap(fetch);
const result = await runWebFetch({
url,
extractMode,
maxChars: resolveMaxChars(maxChars ?? fetch?.maxChars, DEFAULT_FETCH_MAX_CHARS),
maxChars: resolveMaxChars(
maxChars ?? fetch?.maxChars,
DEFAULT_FETCH_MAX_CHARS,
maxCharsCap,
),
maxRedirects: resolveMaxRedirects(fetch?.maxRedirects, DEFAULT_FETCH_MAX_REDIRECTS),
timeoutSeconds: resolveTimeoutSeconds(fetch?.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS),
cacheTtlMs: resolveCacheTtlMs(fetch?.cacheTtlMinutes, DEFAULT_CACHE_TTL_MINUTES),

View File

@@ -49,6 +49,20 @@ function firecrawlError(): MockResponse {
};
}
function textResponse(
text: string,
url = "https://example.com/",
contentType = "text/plain; charset=utf-8",
): MockResponse {
return {
ok: true,
status: 200,
url,
headers: makeHeaders({ "content-type": contentType }),
text: async () => text,
};
}
function errorHtmlResponse(
html: string,
status = 404,
@@ -322,6 +336,37 @@ describe("web_fetch extraction fallbacks", () => {
expect(details.extractor).toBe("firecrawl");
expect(details.text).toContain("firecrawl fallback");
});
it("wraps external content and clamps oversized maxChars", async () => {
const large = "a".repeat(80_000);
const mockFetch = vi.fn(
(input: RequestInfo) =>
Promise.resolve(textResponse(large, requestUrl(input))) as Promise<Response>,
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebFetchTool({
config: {
tools: {
web: {
fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false }, maxCharsCap: 10_000 },
},
},
},
sandboxed: false,
});
const result = await tool?.execute?.("call", {
url: "https://example.com/large",
maxChars: 200_000,
});
const details = result?.details as { text?: string; length?: number; truncated?: boolean };
expect(details.text).toContain("<<<EXTERNAL_UNTRUSTED_CONTENT>>>");
expect(details.text).toContain("Source: Web Fetch");
expect(details.length).toBeLessThanOrEqual(10_000);
expect(details.truncated).toBe(true);
});
it("strips and truncates HTML from error responses", async () => {
const long = "x".repeat(12_000);
const html =

View File

@@ -10,10 +10,112 @@ import {
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import {
buildModelsKeyboard,
buildProviderKeyboard,
calculateTotalPages,
getModelsPageSize,
type ProviderInfo,
} from "../../telegram/model-buttons.js";
const PAGE_SIZE_DEFAULT = 20;
const PAGE_SIZE_MAX = 100;
export type ModelsProviderData = {
byProvider: Map<string, Set<string>>;
providers: string[];
resolvedDefault: { provider: string; model: string };
};
/**
* Build provider/model data from config and catalog.
* Exported for reuse by callback handlers.
*/
export async function buildModelsProviderData(cfg: OpenClawConfig): Promise<ModelsProviderData> {
const resolvedDefault = resolveConfiguredModelRef({
cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const catalog = await loadModelCatalog({ config: cfg });
const allowed = buildAllowedModelSet({
cfg,
catalog,
defaultProvider: resolvedDefault.provider,
defaultModel: resolvedDefault.model,
});
const aliasIndex = buildModelAliasIndex({
cfg,
defaultProvider: resolvedDefault.provider,
});
const byProvider = new Map<string, Set<string>>();
const add = (p: string, m: string) => {
const key = normalizeProviderId(p);
const set = byProvider.get(key) ?? new Set<string>();
set.add(m);
byProvider.set(key, set);
};
const addRawModelRef = (raw?: string) => {
const trimmed = raw?.trim();
if (!trimmed) {
return;
}
const resolved = resolveModelRefFromString({
raw: trimmed,
defaultProvider: resolvedDefault.provider,
aliasIndex,
});
if (!resolved) {
return;
}
add(resolved.ref.provider, resolved.ref.model);
};
const addModelConfigEntries = () => {
const modelConfig = cfg.agents?.defaults?.model;
if (typeof modelConfig === "string") {
addRawModelRef(modelConfig);
} else if (modelConfig && typeof modelConfig === "object") {
addRawModelRef(modelConfig.primary);
for (const fallback of modelConfig.fallbacks ?? []) {
addRawModelRef(fallback);
}
}
const imageConfig = cfg.agents?.defaults?.imageModel;
if (typeof imageConfig === "string") {
addRawModelRef(imageConfig);
} else if (imageConfig && typeof imageConfig === "object") {
addRawModelRef(imageConfig.primary);
for (const fallback of imageConfig.fallbacks ?? []) {
addRawModelRef(fallback);
}
}
};
for (const entry of allowed.allowedCatalog) {
add(entry.provider, entry.id);
}
// Include config-only allowlist keys that aren't in the curated catalog.
for (const raw of Object.keys(cfg.agents?.defaults?.models ?? {})) {
addRawModelRef(raw);
}
// Ensure configured defaults/fallbacks/image models show up even when the
// curated catalog doesn't know about them (custom providers, dev builds, etc.).
add(resolvedDefault.provider, resolvedDefault.model);
addModelConfigEntries();
const providers = [...byProvider.keys()].toSorted();
return { byProvider, providers, resolvedDefault };
}
function formatProviderLine(params: { provider: string; count: number }): string {
return `- ${params.provider} (${params.count})`;
}
@@ -78,6 +180,8 @@ function parseModelsArgs(raw: string): {
export async function resolveModelsCommandReply(params: {
cfg: OpenClawConfig;
commandBodyNormalized: string;
surface?: string;
currentModel?: string;
}): Promise<ReplyPayload | null> {
const body = params.commandBodyNormalized.trim();
if (!body.startsWith("/models")) {
@@ -87,88 +191,26 @@ export async function resolveModelsCommandReply(params: {
const argText = body.replace(/^\/models\b/i, "").trim();
const { provider, page, pageSize, all } = parseModelsArgs(argText);
const resolvedDefault = resolveConfiguredModelRef({
cfg: params.cfg,
defaultProvider: DEFAULT_PROVIDER,
defaultModel: DEFAULT_MODEL,
});
const catalog = await loadModelCatalog({ config: params.cfg });
const allowed = buildAllowedModelSet({
cfg: params.cfg,
catalog,
defaultProvider: resolvedDefault.provider,
defaultModel: resolvedDefault.model,
});
const aliasIndex = buildModelAliasIndex({
cfg: params.cfg,
defaultProvider: resolvedDefault.provider,
});
const byProvider = new Map<string, Set<string>>();
const add = (p: string, m: string) => {
const key = normalizeProviderId(p);
const set = byProvider.get(key) ?? new Set<string>();
set.add(m);
byProvider.set(key, set);
};
const addRawModelRef = (raw?: string) => {
const trimmed = raw?.trim();
if (!trimmed) {
return;
}
const resolved = resolveModelRefFromString({
raw: trimmed,
defaultProvider: resolvedDefault.provider,
aliasIndex,
});
if (!resolved) {
return;
}
add(resolved.ref.provider, resolved.ref.model);
};
const addModelConfigEntries = () => {
const modelConfig = params.cfg.agents?.defaults?.model;
if (typeof modelConfig === "string") {
addRawModelRef(modelConfig);
} else if (modelConfig && typeof modelConfig === "object") {
addRawModelRef(modelConfig.primary);
for (const fallback of modelConfig.fallbacks ?? []) {
addRawModelRef(fallback);
}
}
const imageConfig = params.cfg.agents?.defaults?.imageModel;
if (typeof imageConfig === "string") {
addRawModelRef(imageConfig);
} else if (imageConfig && typeof imageConfig === "object") {
addRawModelRef(imageConfig.primary);
for (const fallback of imageConfig.fallbacks ?? []) {
addRawModelRef(fallback);
}
}
};
for (const entry of allowed.allowedCatalog) {
add(entry.provider, entry.id);
}
// Include config-only allowlist keys that aren't in the curated catalog.
for (const raw of Object.keys(params.cfg.agents?.defaults?.models ?? {})) {
addRawModelRef(raw);
}
// Ensure configured defaults/fallbacks/image models show up even when the
// curated catalog doesn't know about them (custom providers, dev builds, etc.).
add(resolvedDefault.provider, resolvedDefault.model);
addModelConfigEntries();
const providers = [...byProvider.keys()].toSorted();
const { byProvider, providers } = await buildModelsProviderData(params.cfg);
const isTelegram = params.surface === "telegram";
// Provider list (no provider specified)
if (!provider) {
// For Telegram: show buttons if there are providers
if (isTelegram && providers.length > 0) {
const providerInfos: ProviderInfo[] = providers.map((p) => ({
id: p,
count: byProvider.get(p)?.size ?? 0,
}));
const buttons = buildProviderKeyboard(providerInfos);
const text = "Select a provider:";
return {
text,
channelData: { telegram: { buttons } },
};
}
// Text fallback for non-Telegram surfaces
const lines: string[] = [
"Providers:",
...providers.map((p) =>
@@ -206,6 +248,29 @@ export async function resolveModelsCommandReply(params: {
return { text: lines.join("\n") };
}
// For Telegram: use button-based model list with inline keyboard pagination
if (isTelegram) {
const telegramPageSize = getModelsPageSize();
const totalPages = calculateTotalPages(total, telegramPageSize);
const safePage = Math.max(1, Math.min(page, totalPages));
const buttons = buildModelsKeyboard({
provider,
models,
currentModel: params.currentModel,
currentPage: safePage,
totalPages,
pageSize: telegramPageSize,
});
const text = `Models (${provider}) — ${total} available`;
return {
text,
channelData: { telegram: { buttons } },
};
}
// Text fallback for non-Telegram surfaces
const effectivePageSize = all ? total : pageSize;
const pageCount = effectivePageSize > 0 ? Math.ceil(total / effectivePageSize) : 1;
const safePage = all ? 1 : Math.max(1, Math.min(page, pageCount));
@@ -251,6 +316,8 @@ export const handleModelsCommand: CommandHandler = async (params, allowTextComma
const reply = await resolveModelsCommandReply({
cfg: params.cfg,
commandBodyNormalized: params.command.commandBodyNormalized,
surface: params.ctx.Surface,
currentModel: params.model ? `${params.provider}/${params.model}` : undefined,
});
if (!reply) {
return null;

View File

@@ -153,7 +153,7 @@ describe("/models command", () => {
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
} as unknown as OpenClawConfig;
it.each(["telegram", "discord", "whatsapp"])("lists providers on %s", async (surface) => {
it.each(["discord", "whatsapp"])("lists providers on %s (text)", async (surface) => {
const params = buildParams("/models", cfg, { Provider: surface, Surface: surface });
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
@@ -162,8 +162,20 @@ describe("/models command", () => {
expect(result.reply?.text).toContain("Use: /models <provider>");
});
it("lists providers on telegram (buttons)", async () => {
const params = buildParams("/models", cfg, { Provider: "telegram", Surface: "telegram" });
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toBe("Select a provider:");
const buttons = (result.reply?.channelData as { telegram?: { buttons?: unknown[][] } })
?.telegram?.buttons;
expect(buttons).toBeDefined();
expect(buttons?.length).toBeGreaterThan(0);
});
it("lists provider models with pagination hints", async () => {
const params = buildParams("/models anthropic", cfg);
// Use discord surface for text-based output tests
const params = buildParams("/models anthropic", cfg, { Surface: "discord" });
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Models (anthropic)");
@@ -174,7 +186,8 @@ describe("/models command", () => {
});
it("ignores page argument when all flag is present", async () => {
const params = buildParams("/models anthropic 3 all", cfg);
// Use discord surface for text-based output tests
const params = buildParams("/models anthropic 3 all", cfg, { Surface: "discord" });
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Models (anthropic)");
@@ -184,7 +197,8 @@ describe("/models command", () => {
});
it("errors on out-of-range pages", async () => {
const params = buildParams("/models anthropic 4", cfg);
// Use discord surface for text-based output tests
const params = buildParams("/models anthropic 4", cfg, { Surface: "discord" });
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Page out of range");
@@ -213,11 +227,16 @@ describe("/models command", () => {
},
} as unknown as OpenClawConfig;
const providerList = await handleCommands(buildParams("/models", customCfg));
// Use discord surface for text-based output tests
const providerList = await handleCommands(
buildParams("/models", customCfg, { Surface: "discord" }),
);
expect(providerList.reply?.text).toContain("localai");
expect(providerList.reply?.text).toContain("visionpro");
const result = await handleCommands(buildParams("/models localai", customCfg));
const result = await handleCommands(
buildParams("/models localai", customCfg, { Surface: "discord" }),
);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Models (localai)");
expect(result.reply?.text).toContain("localai/ultra-chat");

View File

@@ -85,6 +85,7 @@ export async function handleDirectiveOnly(params: {
currentVerboseLevel?: VerboseLevel;
currentReasoningLevel?: ReasoningLevel;
currentElevatedLevel?: ElevatedLevel;
surface?: string;
}): Promise<ReplyPayload | undefined> {
const {
directives,
@@ -132,6 +133,7 @@ export async function handleDirectiveOnly(params: {
aliasIndex,
allowedModelCatalog,
resetModelOverride,
surface: params.surface,
});
if (modelInfo) {
return modelInfo;

View File

@@ -9,6 +9,7 @@ import {
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { buildBrowseProvidersButton } from "../../telegram/model-buttons.js";
import { shortenHomePath } from "../../utils.js";
import { resolveModelsCommandReply } from "./commands-models.js";
import {
@@ -177,6 +178,7 @@ export async function maybeHandleModelDirectiveInfo(params: {
aliasIndex: ModelAliasIndex;
allowedModelCatalog: Array<{ provider: string; id?: string; name?: string }>;
resetModelOverride: boolean;
surface?: string;
}): Promise<ReplyPayload | undefined> {
if (!params.directives.hasModelDirective) {
return undefined;
@@ -213,6 +215,22 @@ export async function maybeHandleModelDirectiveInfo(params: {
if (wantsSummary) {
const current = `${params.provider}/${params.model}`;
const isTelegram = params.surface === "telegram";
if (isTelegram) {
const buttons = buildBrowseProvidersButton();
return {
text: [
`Current: ${current}`,
"",
"Tap below to browse models, or use:",
"/model <provider/model> to switch",
"/model status for details",
].join("\n"),
channelData: { telegram: { buttons } },
};
}
return {
text: [
`Current: ${current}`,

View File

@@ -183,6 +183,7 @@ export async function applyInlineDirectiveOverrides(params: {
currentVerboseLevel,
currentReasoningLevel,
currentElevatedLevel,
surface: ctx.Surface,
});
let statusReply: ReplyPayload | undefined;
if (directives.hasStatusDirective && allowTextCommands && command.isAuthorizedSender) {

View File

@@ -43,6 +43,7 @@ import { resolveQueueSettings } from "./queue.js";
import { routeReply } from "./route-reply.js";
import { ensureSkillSnapshot, prependSystemEvents } from "./session-updates.js";
import { resolveTypingMode } from "./typing-mode.js";
import { appendUntrustedContext } from "./untrusted-context.js";
type AgentDefaults = NonNullable<OpenClawConfig["agents"]>["defaults"];
type ExecOverrides = Pick<ExecToolDefaults, "host" | "security" | "ask" | "node">;
@@ -227,6 +228,7 @@ export async function runPreparedReply(
isNewSession,
prefixedBodyBase,
});
prefixedBodyBase = appendUntrustedContext(prefixedBodyBase, sessionCtx.UntrustedContext);
const threadStarterBody = ctx.ThreadStarterBody?.trim();
const threadStarterNote =
isNewSession && threadStarterBody

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