Merge remote-tracking branch 'upstream/main' into fix-fake-ip-ssrf-guard

This commit is contained in:
sunkinux 2026-03-16 14:11:54 +08:00
commit 7d43cdea95
163 changed files with 5775 additions and 2010 deletions

View File

@ -24,6 +24,8 @@ Docs: https://docs.openclaw.ai
- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant.
- Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend with `mirror` and `remote` workspace modes, and make sandbox list/recreate/prune backend-aware instead of Docker-only.
- Sandbox/SSH: add a core SSH sandbox backend with secret-backed key, certificate, and known_hosts inputs, move shared remote exec/filesystem tooling into core, and keep OpenShell focused on sandbox lifecycle plus optional `mirror` mode.
- Feishu/cards: add structured interactive approval and quick-action launcher cards, preserve callback user and conversation context through routing, and keep legacy card-action fallback behavior so common actions can run without typing raw commands. (#47873)
- Feishu/ACP: add current-conversation ACP and subagent session binding for supported DMs and topic conversations, including completion delivery back to the originating Feishu conversation. (#46819)
### Fixes

View File

@ -58804,7 +58804,7 @@
"advanced"
],
"label": "Setup Wizard State",
"help": "Setup wizard state tracking fields that record the most recent guided onboarding run details. Keep these fields for observability and troubleshooting of setup flows across upgrades.",
"help": "Setup wizard state tracking fields that record the most recent guided setup run details. Keep these fields for observability and troubleshooting of setup flows across upgrades.",
"hasChildren": true
},
{
@ -58818,7 +58818,7 @@
"advanced"
],
"label": "Wizard Last Run Timestamp",
"help": "ISO timestamp for when the setup wizard most recently completed on this host. Use this to confirm onboarding recency during support and operational audits.",
"help": "ISO timestamp for when the setup wizard most recently completed on this host. Use this to confirm setup recency during support and operational audits.",
"hasChildren": false
},
{
@ -58832,7 +58832,7 @@
"advanced"
],
"label": "Wizard Last Run Command",
"help": "Command invocation recorded for the latest wizard run to preserve execution context. Use this to reproduce onboarding steps when verifying setup regressions.",
"help": "Command invocation recorded for the latest wizard run to preserve execution context. Use this to reproduce setup steps when verifying setup regressions.",
"hasChildren": false
},
{
@ -58846,7 +58846,7 @@
"advanced"
],
"label": "Wizard Last Run Commit",
"help": "Source commit identifier recorded for the last wizard execution in development builds. Use this to correlate onboarding behavior with exact source state during debugging.",
"help": "Source commit identifier recorded for the last wizard execution in development builds. Use this to correlate setup behavior with exact source state during debugging.",
"hasChildren": false
},
{
@ -58874,7 +58874,7 @@
"advanced"
],
"label": "Wizard Last Run Version",
"help": "OpenClaw version recorded at the time of the most recent wizard run on this config. Use this when diagnosing behavior differences across version-to-version onboarding changes.",
"help": "OpenClaw version recorded at the time of the most recent wizard run on this config. Use this when diagnosing behavior differences across version-to-version setup changes.",
"hasChildren": false
}
]

View File

@ -5086,9 +5086,9 @@
{"recordType":"path","path":"web.reconnect.jitter","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Web Reconnect Jitter","help":"Randomization factor (0-1) applied to reconnect delays to desynchronize clients after outage events. Keep non-zero jitter in multi-client deployments to reduce synchronized spikes.","hasChildren":false}
{"recordType":"path","path":"web.reconnect.maxAttempts","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Web Reconnect Max Attempts","help":"Maximum reconnect attempts before giving up for the current failure sequence (0 means no retries). Use finite caps for controlled failure handling in automation-sensitive environments.","hasChildren":false}
{"recordType":"path","path":"web.reconnect.maxMs","kind":"core","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Web Reconnect Max Delay (ms)","help":"Maximum reconnect backoff cap in milliseconds to bound retry delay growth over repeated failures. Use a reasonable cap so recovery remains timely after prolonged outages.","hasChildren":false}
{"recordType":"path","path":"wizard","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Setup Wizard State","help":"Setup wizard state tracking fields that record the most recent guided onboarding run details. Keep these fields for observability and troubleshooting of setup flows across upgrades.","hasChildren":true}
{"recordType":"path","path":"wizard.lastRunAt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Timestamp","help":"ISO timestamp for when the setup wizard most recently completed on this host. Use this to confirm onboarding recency during support and operational audits.","hasChildren":false}
{"recordType":"path","path":"wizard.lastRunCommand","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Command","help":"Command invocation recorded for the latest wizard run to preserve execution context. Use this to reproduce onboarding steps when verifying setup regressions.","hasChildren":false}
{"recordType":"path","path":"wizard.lastRunCommit","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Commit","help":"Source commit identifier recorded for the last wizard execution in development builds. Use this to correlate onboarding behavior with exact source state during debugging.","hasChildren":false}
{"recordType":"path","path":"wizard","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Setup Wizard State","help":"Setup wizard state tracking fields that record the most recent guided setup run details. Keep these fields for observability and troubleshooting of setup flows across upgrades.","hasChildren":true}
{"recordType":"path","path":"wizard.lastRunAt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Timestamp","help":"ISO timestamp for when the setup wizard most recently completed on this host. Use this to confirm setup recency during support and operational audits.","hasChildren":false}
{"recordType":"path","path":"wizard.lastRunCommand","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Command","help":"Command invocation recorded for the latest wizard run to preserve execution context. Use this to reproduce setup steps when verifying setup regressions.","hasChildren":false}
{"recordType":"path","path":"wizard.lastRunCommit","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Commit","help":"Source commit identifier recorded for the last wizard execution in development builds. Use this to correlate setup behavior with exact source state during debugging.","hasChildren":false}
{"recordType":"path","path":"wizard.lastRunMode","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Mode","help":"Wizard execution mode recorded as \"local\" or \"remote\" for the most recent setup flow. Use this to understand whether setup targeted direct local runtime or remote gateway topology.","hasChildren":false}
{"recordType":"path","path":"wizard.lastRunVersion","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Version","help":"OpenClaw version recorded at the time of the most recent wizard run on this config. Use this when diagnosing behavior differences across version-to-version onboarding changes.","hasChildren":false}
{"recordType":"path","path":"wizard.lastRunVersion","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Wizard Last Run Version","help":"OpenClaw version recorded at the time of the most recent wizard run on this config. Use this when diagnosing behavior differences across version-to-version setup changes.","hasChildren":false}

View File

@ -55,10 +55,6 @@
"source": "CLI Setup Reference",
"target": "CLI 设置参考"
},
{
"source": "Setup Overview",
"target": "设置概览"
},
{
"source": "Setup Wizard (CLI)",
"target": "设置向导CLI"

View File

@ -74,7 +74,7 @@ openclaw hooks info session-memory
### Onboarding
During onboarding (`openclaw setup --wizard`), you'll be prompted to enable recommended hooks. The wizard automatically discovers eligible hooks and presents them for selection.
During onboarding (`openclaw onboard`), you'll be prompted to enable recommended hooks. The wizard automatically discovers eligible hooks and presents them for selection.
## Hook Discovery

View File

@ -26,7 +26,7 @@ Status: bundled plugin that talks to the BlueBubbles macOS server over HTTP. **R
1. Install the BlueBubbles server on your Mac (follow the instructions at [bluebubbles.app/install](https://bluebubbles.app/install)).
2. In the BlueBubbles config, enable the web API and set a password.
3. Run `openclaw setup --wizard` and select BlueBubbles, or configure manually:
3. Run `openclaw onboard` and select BlueBubbles, or configure manually:
```json5
{
@ -129,7 +129,7 @@ launchctl load ~/Library/LaunchAgents/com.user.poke-messages.plist
BlueBubbles is available in the interactive setup wizard:
```
openclaw setup --wizard
openclaw onboard
```
The wizard prompts for:

View File

@ -35,7 +35,7 @@ There are two ways to add the Feishu channel:
If you just installed OpenClaw, run the setup wizard:
```bash
openclaw setup --wizard
openclaw onboard
```
The wizard guides you through:

View File

@ -16,7 +16,7 @@ Nostr is a decentralized protocol for social networking. This channel enables Op
### Onboarding (recommended)
- The setup wizard (`openclaw setup --wizard`) and `openclaw channels add` list optional channel plugins.
- The setup wizard (`openclaw onboard`) and `openclaw channels add` list optional channel plugins.
- Selecting Nostr prompts you to install the plugin on demand.
Install defaults:

View File

@ -27,7 +27,7 @@ Details: [Plugins](/tools/plugin)
## Quick setup
1. Install and enable the Synology Chat plugin.
- `openclaw setup --wizard` now shows Synology Chat in the same channel setup list as `openclaw channels add`.
- `openclaw onboard` now shows Synology Chat in the same channel setup list as `openclaw channels add`.
- Non-interactive setup: `openclaw channels add --channel synology-chat --token <token> --url <incoming-webhook-url>`
2. In Synology Chat integrations:
- Create an incoming webhook and copy its URL.
@ -36,7 +36,7 @@ Details: [Plugins](/tools/plugin)
- `https://gateway-host/webhook/synology` by default.
- Or your custom `channels.synology-chat.webhookPath`.
4. Finish setup in OpenClaw.
- Guided: `openclaw setup --wizard`
- Guided: `openclaw onboard`
- Direct: `openclaw channels add --channel synology-chat --token <token> --url <incoming-webhook-url>`
5. Restart gateway and send a DM to the Synology Chat bot.

View File

@ -13,7 +13,7 @@ This page describes the current CLI behavior. If commands change, update this do
## Command pages
- [`setup`](/cli/setup)
- [`onboard`](/cli/onboard) (legacy alias for `setup --wizard`)
- [`onboard`](/cli/onboard)
- [`configure`](/cli/configure)
- [`config`](/cli/config)
- [`completion`](/cli/completion)

View File

@ -1,30 +1,157 @@
---
summary: "Legacy CLI alias for `openclaw setup --wizard`"
summary: "CLI reference for `openclaw onboard` (interactive setup wizard)"
read_when:
- You encountered `openclaw onboard` in older docs or scripts
- You want guided setup for gateway, workspace, auth, channels, and skills
title: "onboard"
---
# `openclaw onboard`
Legacy alias for `openclaw setup --wizard`.
Prefer:
```bash
openclaw setup --wizard
```
`openclaw onboard` still accepts the same flags and behavior for compatibility.
Interactive setup wizard (local or remote Gateway setup).
## Related guides
- Primary command docs: [`openclaw setup`](/cli/setup)
- Setup wizard guide: [Setup Wizard (CLI)](/start/wizard)
- Setup overview: [Setup Overview](/start/onboarding-overview)
- Setup wizard reference: [CLI Setup Reference](/start/wizard-cli-reference)
- CLI onboarding hub: [Setup Wizard (CLI)](/start/wizard)
- Onboarding overview: [Onboarding Overview](/start/onboarding-overview)
- CLI onboarding reference: [CLI Setup Reference](/start/wizard-cli-reference)
- CLI automation: [CLI Automation](/start/wizard-cli-automation)
- macOS onboarding: [Onboarding (macOS App)](/start/onboarding)
For examples, flags, and non-interactive behavior, use the primary docs at
[`openclaw setup`](/cli/setup) and [CLI Setup Reference](/start/wizard-cli-reference).
## Examples
```bash
openclaw onboard
openclaw onboard --flow quickstart
openclaw onboard --flow manual
openclaw onboard --mode remote --remote-url wss://gateway-host:18789
```
For plaintext private-network `ws://` targets (trusted networks only), set
`OPENCLAW_ALLOW_INSECURE_PRIVATE_WS=1` in the onboarding process environment.
Non-interactive custom provider:
```bash
openclaw onboard --non-interactive \
--auth-choice custom-api-key \
--custom-base-url "https://llm.example.com/v1" \
--custom-model-id "foo-large" \
--custom-api-key "$CUSTOM_API_KEY" \
--secret-input-mode plaintext \
--custom-compatibility openai
```
`--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`.
Non-interactive Ollama:
```bash
openclaw onboard --non-interactive \
--auth-choice ollama \
--custom-base-url "http://ollama-host:11434" \
--custom-model-id "qwen3.5:27b" \
--accept-risk
```
`--custom-base-url` defaults to `http://127.0.0.1:11434`. `--custom-model-id` is optional; if omitted, onboarding uses Ollama's suggested defaults. Cloud model IDs such as `kimi-k2.5:cloud` also work here.
Store provider keys as refs instead of plaintext:
```bash
openclaw onboard --non-interactive \
--auth-choice openai-api-key \
--secret-input-mode ref \
--accept-risk
```
With `--secret-input-mode ref`, onboarding writes env-backed refs instead of plaintext key values.
For auth-profile backed providers this writes `keyRef` entries; for custom providers this writes `models.providers.<id>.apiKey` as an env ref (for example `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`).
Non-interactive `ref` mode contract:
- Set the provider env var in the onboarding process environment (for example `OPENAI_API_KEY`).
- Do not pass inline key flags (for example `--openai-api-key`) unless that env var is also set.
- If an inline key flag is passed without the required env var, onboarding fails fast with guidance.
Gateway token options in non-interactive mode:
- `--gateway-auth token --gateway-token <token>` stores a plaintext token.
- `--gateway-auth token --gateway-token-ref-env <name>` stores `gateway.auth.token` as an env SecretRef.
- `--gateway-token` and `--gateway-token-ref-env` are mutually exclusive.
- `--gateway-token-ref-env` requires a non-empty env var in the onboarding process environment.
- With `--install-daemon`, when token auth requires a token, SecretRef-managed gateway tokens are validated but not persisted as resolved plaintext in supervisor service environment metadata.
- With `--install-daemon`, if token mode requires a token and the configured token SecretRef is unresolved, onboarding fails closed with remediation guidance.
- With `--install-daemon`, if both `gateway.auth.token` and `gateway.auth.password` are configured and `gateway.auth.mode` is unset, onboarding blocks install until mode is set explicitly.
Example:
```bash
export OPENCLAW_GATEWAY_TOKEN="your-token"
openclaw onboard --non-interactive \
--mode local \
--auth-choice skip \
--gateway-auth token \
--gateway-token-ref-env OPENCLAW_GATEWAY_TOKEN \
--accept-risk
```
Non-interactive local gateway health:
- Unless you pass `--skip-health`, onboarding waits for a reachable local gateway before it exits successfully.
- `--install-daemon` starts the managed gateway install path first. Without it, you must already have a local gateway running, for example `openclaw gateway run`.
- If you only want config/workspace/bootstrap writes in automation, use `--skip-health`.
- On native Windows, `--install-daemon` tries Scheduled Tasks first and falls back to a per-user Startup-folder login item if task creation is denied.
Interactive onboarding behavior with reference mode:
- Choose **Use secret reference** when prompted.
- Then choose either:
- Environment variable
- Configured secret provider (`file` or `exec`)
- Onboarding performs a fast preflight validation before saving the ref.
- If validation fails, onboarding shows the error and lets you retry.
Non-interactive Z.AI endpoint choices:
Note: `--auth-choice zai-api-key` now auto-detects the best Z.AI endpoint for your key (prefers the general API with `zai/glm-5`).
If you specifically want the GLM Coding Plan endpoints, pick `zai-coding-global` or `zai-coding-cn`.
```bash
# Promptless endpoint selection
openclaw onboard --non-interactive \
--auth-choice zai-coding-global \
--zai-api-key "$ZAI_API_KEY"
# Other Z.AI endpoint choices:
# --auth-choice zai-coding-cn
# --auth-choice zai-global
# --auth-choice zai-cn
```
Non-interactive Mistral example:
```bash
openclaw onboard --non-interactive \
--auth-choice mistral-api-key \
--mistral-api-key "$MISTRAL_API_KEY"
```
Flow notes:
- `quickstart`: minimal prompts, auto-generates a gateway token.
- `manual`: full prompts for port/bind/auth (alias of `advanced`).
- Local onboarding DM scope behavior: [CLI Setup Reference](/start/wizard-cli-reference#outputs-and-internals).
- Fastest first chat: `openclaw dashboard` (Control UI, no channel setup).
- Custom Provider: connect any OpenAI or Anthropic compatible endpoint,
including hosted providers not listed. Use Unknown to auto-detect.
## Common follow-up commands
```bash
openclaw configure
openclaw agents add <name>
```
<Note>
`--json` does not imply non-interactive mode. Use `--non-interactive` for scripts.
</Note>

View File

@ -1,43 +1,29 @@
---
summary: "CLI reference for `openclaw setup` (initialize config/workspace or run the setup wizard)"
summary: "CLI reference for `openclaw setup` (initialize config + workspace)"
read_when:
- You want first-run setup without the guided wizard
- You want the guided setup wizard via `openclaw setup --wizard`
- Youre doing first-run setup without the full setup wizard
- You want to set the default workspace path
title: "setup"
---
# `openclaw setup`
Initialize `~/.openclaw/openclaw.json` and the agent workspace, or run the guided setup wizard.
Initialize `~/.openclaw/openclaw.json` and the agent workspace.
Related:
- Getting started: [Getting started](/start/getting-started)
- Setup wizard: [Setup Wizard (CLI)](/start/wizard)
- macOS app onboarding: [Onboarding](/start/onboarding)
- Wizard: [Onboarding](/start/onboarding)
## Examples
```bash
openclaw setup
openclaw setup --workspace ~/.openclaw/workspace
openclaw setup --wizard
openclaw setup --wizard --install-daemon
```
Without flags, `openclaw setup` only ensures config + workspace defaults.
Use `--wizard` for the full guided flow.
To run the wizard via setup:
## Modes
- `openclaw setup`: initialize config/workspace defaults only
- `openclaw setup --wizard`: guided setup for auth, gateway, channels, and skills
- `openclaw setup --wizard --non-interactive`: scripted setup flow
## Related guides
- Setup wizard guide: [Setup Wizard (CLI)](/start/wizard)
- Setup wizard reference: [CLI Setup Reference](/start/wizard-cli-reference)
- Setup wizard automation: [CLI Automation](/start/wizard-cli-automation)
- Legacy alias: [`openclaw onboard`](/cli/onboard)
```bash
openclaw setup --wizard
```

View File

@ -36,7 +36,7 @@ inside a sandbox workspace under `~/.openclaw/sandboxes`, not your host workspac
}
```
`openclaw setup --wizard`, `openclaw configure`, or `openclaw setup` will create the
`openclaw onboard`, `openclaw configure`, or `openclaw setup` will create the
workspace and seed the bootstrap files if they are missing.
Sandbox seed copies only accept regular in-workspace files; symlink/hardlink
aliases that resolve outside the source workspace are ignored.

View File

@ -15,7 +15,7 @@ For model selection rules, see [/concepts/models](/concepts/models).
- Model refs use `provider/model` (example: `opencode/claude-opus-4-6`).
- If you set `agents.defaults.models`, it becomes the allowlist.
- CLI helpers: `openclaw setup --wizard`, `openclaw models list`, `openclaw models set <provider/model>`.
- CLI helpers: `openclaw onboard`, `openclaw models list`, `openclaw models set <provider/model>`.
- Provider plugins can inject model catalogs via `registerProvider({ catalog })`;
OpenClaw merges that output into `models.providers` before writing
`models.json`.
@ -42,8 +42,8 @@ Typical split:
- `auth[].run` / `auth[].runNonInteractive`: provider owns onboarding/login
flows for `openclaw onboard`, `openclaw models auth`, and headless setup
- `wizard.onboarding` / `wizard.modelPicker`: provider owns auth-choice labels,
hints, and setup entries in onboarding/model pickers
- `wizard.setup` / `wizard.modelPicker`: provider owns auth-choice labels,
legacy aliases, onboarding allowlist hints, and setup entries in onboarding/model pickers
- `catalog`: provider appears in `models.providers`
- `resolveDynamicModel`: provider accepts model ids not present in the local
static catalog yet
@ -139,7 +139,7 @@ OpenClaw ships with the piai catalog. These providers require **no**
- Auth: `OPENAI_API_KEY`
- Optional rotation: `OPENAI_API_KEYS`, `OPENAI_API_KEY_1`, `OPENAI_API_KEY_2`, plus `OPENCLAW_LIVE_OPENAI_KEY` (single override)
- Example models: `openai/gpt-5.4`, `openai/gpt-5.4-pro`
- CLI: `openclaw setup --wizard --auth-choice openai-api-key`
- CLI: `openclaw onboard --auth-choice openai-api-key`
- Default transport is `auto` (WebSocket-first, SSE fallback)
- Override per model via `agents.defaults.models["openai/<model>"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`)
- OpenAI Responses WebSocket warm-up defaults to enabled via `params.openaiWsWarmup` (`true`/`false`)
@ -159,7 +159,7 @@ OpenClaw ships with the piai catalog. These providers require **no**
- Auth: `ANTHROPIC_API_KEY` or `claude setup-token`
- Optional rotation: `ANTHROPIC_API_KEYS`, `ANTHROPIC_API_KEY_1`, `ANTHROPIC_API_KEY_2`, plus `OPENCLAW_LIVE_ANTHROPIC_KEY` (single override)
- Example model: `anthropic/claude-opus-4-6`
- CLI: `openclaw setup --wizard --auth-choice token` (paste setup-token) or `openclaw models auth paste-token --provider anthropic`
- CLI: `openclaw onboard --auth-choice token` (paste setup-token) or `openclaw models auth paste-token --provider anthropic`
- Direct API-key models support the shared `/fast` toggle and `params.fastMode`; OpenClaw maps that to Anthropic `service_tier` (`auto` vs `standard_only`)
- Policy note: setup-token support is technical compatibility; Anthropic has blocked some subscription usage outside Claude Code in the past. Verify current Anthropic terms and decide based on your risk tolerance.
- Recommendation: Anthropic API key auth is the safer, recommended path over subscription setup-token auth.
@ -175,7 +175,7 @@ OpenClaw ships with the piai catalog. These providers require **no**
- Provider: `openai-codex`
- Auth: OAuth (ChatGPT)
- Example model: `openai-codex/gpt-5.4`
- CLI: `openclaw setup --wizard --auth-choice openai-codex` or `openclaw models auth login --provider openai-codex`
- CLI: `openclaw onboard --auth-choice openai-codex` or `openclaw models auth login --provider openai-codex`
- Default transport is `auto` (WebSocket-first, SSE fallback)
- Override per model via `agents.defaults.models["openai-codex/<model>"].params.transport` (`"sse"`, `"websocket"`, or `"auto"`)
- Shares the same `/fast` toggle and `params.fastMode` config as direct `openai/*`
@ -194,7 +194,7 @@ OpenClaw ships with the piai catalog. These providers require **no**
- Zen runtime provider: `opencode`
- Go runtime provider: `opencode-go`
- Example models: `opencode/claude-opus-4-6`, `opencode-go/kimi-k2.5`
- CLI: `openclaw setup --wizard --auth-choice opencode-zen` or `openclaw setup --wizard --auth-choice opencode-go`
- CLI: `openclaw onboard --auth-choice opencode-zen` or `openclaw onboard --auth-choice opencode-go`
```json5
{
@ -209,7 +209,7 @@ OpenClaw ships with the piai catalog. These providers require **no**
- Optional rotation: `GEMINI_API_KEYS`, `GEMINI_API_KEY_1`, `GEMINI_API_KEY_2`, `GOOGLE_API_KEY` fallback, and `OPENCLAW_LIVE_GEMINI_KEY` (single override)
- Example models: `google/gemini-3.1-pro-preview`, `google/gemini-3-flash-preview`
- Compatibility: legacy OpenClaw config using `google/gemini-3.1-flash-preview` is normalized to `google/gemini-3-flash-preview`
- CLI: `openclaw setup --wizard --auth-choice gemini-api-key`
- CLI: `openclaw onboard --auth-choice gemini-api-key`
### Google Vertex and Gemini CLI
@ -227,7 +227,7 @@ OpenClaw ships with the piai catalog. These providers require **no**
- Provider: `zai`
- Auth: `ZAI_API_KEY`
- Example model: `zai/glm-5`
- CLI: `openclaw setup --wizard --auth-choice zai-api-key`
- CLI: `openclaw onboard --auth-choice zai-api-key`
- Aliases: `z.ai/*` and `z-ai/*` normalize to `zai/*`
### Vercel AI Gateway
@ -235,14 +235,14 @@ OpenClaw ships with the piai catalog. These providers require **no**
- Provider: `vercel-ai-gateway`
- Auth: `AI_GATEWAY_API_KEY`
- Example model: `vercel-ai-gateway/anthropic/claude-opus-4.6`
- CLI: `openclaw setup --wizard --auth-choice ai-gateway-api-key`
- CLI: `openclaw onboard --auth-choice ai-gateway-api-key`
### Kilo Gateway
- Provider: `kilocode`
- Auth: `KILOCODE_API_KEY`
- Example model: `kilocode/anthropic/claude-opus-4.6`
- CLI: `openclaw setup --wizard --kilocode-api-key <key>`
- CLI: `openclaw onboard --kilocode-api-key <key>`
- Base URL: `https://api.kilo.ai/api/gateway/`
- Expanded built-in catalog includes GLM-5 Free, MiniMax M2.5 Free, GPT-5.2, Gemini 3 Pro Preview, Gemini 3 Flash Preview, Grok Code Fast 1, and Kimi K2.5.
@ -271,13 +271,13 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
- xAI: `xai` (`XAI_API_KEY`)
- Mistral: `mistral` (`MISTRAL_API_KEY`)
- Example model: `mistral/mistral-large-latest`
- CLI: `openclaw setup --wizard --auth-choice mistral-api-key`
- CLI: `openclaw onboard --auth-choice mistral-api-key`
- Groq: `groq` (`GROQ_API_KEY`)
- Cerebras: `cerebras` (`CEREBRAS_API_KEY`)
- GLM models on Cerebras use ids `zai-glm-4.7` and `zai-glm-4.6`.
- OpenAI-compatible base URL: `https://api.cerebras.ai/v1`.
- GitHub Copilot: `github-copilot` (`COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN`)
- Hugging Face Inference example model: `huggingface/deepseek-ai/DeepSeek-R1`; CLI: `openclaw setup --wizard --auth-choice huggingface-api-key`. See [Hugging Face (Inference)](/providers/huggingface).
- Hugging Face Inference example model: `huggingface/deepseek-ai/DeepSeek-R1`; CLI: `openclaw onboard --auth-choice huggingface-api-key`. See [Hugging Face (Inference)](/providers/huggingface).
## Providers via `models.providers` (custom/base URL)
@ -367,7 +367,7 @@ Volcano Engine (火山引擎) provides access to Doubao and other models in Chin
- Provider: `volcengine` (coding: `volcengine-plan`)
- Auth: `VOLCANO_ENGINE_API_KEY`
- Example model: `volcengine/doubao-seed-1-8-251228`
- CLI: `openclaw setup --wizard --auth-choice volcengine-api-key`
- CLI: `openclaw onboard --auth-choice volcengine-api-key`
```json5
{
@ -400,7 +400,7 @@ BytePlus ARK provides access to the same models as Volcano Engine for internatio
- Provider: `byteplus` (coding: `byteplus-plan`)
- Auth: `BYTEPLUS_API_KEY`
- Example model: `byteplus/seed-1-8-251228`
- CLI: `openclaw setup --wizard --auth-choice byteplus-api-key`
- CLI: `openclaw onboard --auth-choice byteplus-api-key`
```json5
{
@ -431,7 +431,7 @@ Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
- Provider: `synthetic`
- Auth: `SYNTHETIC_API_KEY`
- Example model: `synthetic/hf:MiniMaxAI/MiniMax-M2.5`
- CLI: `openclaw setup --wizard --auth-choice synthetic-api-key`
- CLI: `openclaw onboard --auth-choice synthetic-api-key`
```json5
{
@ -485,7 +485,7 @@ ollama pull llama3.3
Ollama is detected locally at `http://127.0.0.1:11434` when you opt in with
`OLLAMA_API_KEY`, and the bundled provider plugin adds Ollama directly to
`openclaw setup --wizard` and the model picker. See [/providers/ollama](/providers/ollama)
`openclaw onboard` and the model picker. See [/providers/ollama](/providers/ollama)
for onboarding, cloud/local mode, and custom configuration.
### vLLM
@ -595,7 +595,7 @@ Notes:
## CLI examples
```bash
openclaw setup --wizard --auth-choice opencode-zen
openclaw onboard --auth-choice opencode-zen
openclaw models set opencode/claude-opus-4-6
openclaw models list
```

View File

@ -39,7 +39,7 @@ Related:
If you dont want to hand-edit config, run the setup wizard:
```bash
openclaw setup --wizard
openclaw onboard
```
It can set up model + auth for common providers, including **OpenAI Code (Codex)

View File

@ -92,7 +92,7 @@ Flow shape:
2. paste the token into OpenClaw
3. store as a token auth profile (no refresh)
The wizard path is `openclaw setup --wizard` → auth choice `setup-token` (Anthropic).
The wizard path is `openclaw onboard` → auth choice `setup-token` (Anthropic).
### OpenAI Codex (ChatGPT OAuth)
@ -107,7 +107,7 @@ Flow shape (PKCE):
5. exchange at `https://auth.openai.com/oauth/token`
6. extract `accountId` from the access token and store `{ access, refresh, expires, accountId }`
Wizard path is `openclaw setup --wizard` → auth choice `openai-codex`.
Wizard path is `openclaw onboard` → auth choice `openai-codex`.
## Refresh + expiry

View File

@ -50,7 +50,7 @@ openclaw doctor
```
If youd rather not manage env vars yourself, the setup wizard can store
API keys for daemon use: `openclaw setup --wizard`.
API keys for daemon use: `openclaw onboard`.
See [Help](/help) for details on env inheritance (`env.shellEnv`,
`~/.openclaw/.env`, systemd/launchd).

View File

@ -2182,7 +2182,7 @@ Use `cerebras/zai-glm-4.7` for Cerebras; `zai/glm-4.7` for Z.AI direct.
}
```
Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Use `opencode/...` refs for the Zen catalog or `opencode-go/...` refs for the Go catalog. Shortcut: `openclaw setup --wizard --auth-choice opencode-zen` or `openclaw setup --wizard --auth-choice opencode-go`.
Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Use `opencode/...` refs for the Zen catalog or `opencode-go/...` refs for the Go catalog. Shortcut: `openclaw onboard --auth-choice opencode-zen` or `openclaw onboard --auth-choice opencode-go`.
</Accordion>
@ -2199,7 +2199,7 @@ Set `OPENCODE_API_KEY` (or `OPENCODE_ZEN_API_KEY`). Use `opencode/...` refs for
}
```
Set `ZAI_API_KEY`. `z.ai/*` and `z-ai/*` are accepted aliases. Shortcut: `openclaw setup --wizard --auth-choice zai-api-key`.
Set `ZAI_API_KEY`. `z.ai/*` and `z-ai/*` are accepted aliases. Shortcut: `openclaw onboard --auth-choice zai-api-key`.
- General endpoint: `https://api.z.ai/api/paas/v4`
- Coding endpoint (default): `https://api.z.ai/api/coding/paas/v4`
@ -2242,7 +2242,7 @@ Set `ZAI_API_KEY`. `z.ai/*` and `z-ai/*` are accepted aliases. Shortcut: `opencl
}
```
For the China endpoint: `baseUrl: "https://api.moonshot.cn/v1"` or `openclaw setup --wizard --auth-choice moonshot-api-key-cn`.
For the China endpoint: `baseUrl: "https://api.moonshot.cn/v1"` or `openclaw onboard --auth-choice moonshot-api-key-cn`.
</Accordion>
@ -2260,7 +2260,7 @@ For the China endpoint: `baseUrl: "https://api.moonshot.cn/v1"` or `openclaw set
}
```
Anthropic-compatible, built-in provider. Shortcut: `openclaw setup --wizard --auth-choice kimi-code-api-key`.
Anthropic-compatible, built-in provider. Shortcut: `openclaw onboard --auth-choice kimi-code-api-key`.
</Accordion>
@ -2299,7 +2299,7 @@ Anthropic-compatible, built-in provider. Shortcut: `openclaw setup --wizard --au
}
```
Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw setup --wizard --auth-choice synthetic-api-key`.
Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw onboard --auth-choice synthetic-api-key`.
</Accordion>
@ -2339,7 +2339,7 @@ Base URL should omit `/v1` (Anthropic client appends it). Shortcut: `openclaw se
}
```
Set `MINIMAX_API_KEY`. Shortcut: `openclaw setup --wizard --auth-choice minimax-api`.
Set `MINIMAX_API_KEY`. Shortcut: `openclaw onboard --auth-choice minimax-api`.
</Accordion>

View File

@ -20,7 +20,7 @@ If the file is missing, OpenClaw uses safe defaults. Common reasons to add a con
See the [full reference](/gateway/configuration-reference) for every available field.
<Tip>
**New to configuration?** Start with `openclaw setup --wizard` for interactive setup, or check out the [Configuration Examples](/gateway/configuration-examples) guide for complete copy-paste configs.
**New to configuration?** Start with `openclaw onboard` for interactive setup, or check out the [Configuration Examples](/gateway/configuration-examples) guide for complete copy-paste configs.
</Tip>
## Minimal config
@ -38,7 +38,7 @@ See the [full reference](/gateway/configuration-reference) for every available f
<Tabs>
<Tab title="Interactive wizard">
```bash
openclaw setup --wizard # full setup wizard
openclaw onboard # full setup wizard
openclaw configure # config wizard
```
</Tab>

View File

@ -11,7 +11,7 @@ title: "Local Models"
Local is doable, but OpenClaw expects large context + strong defenses against prompt injection. Small cards truncate context and leak safety. Aim high: **≥2 maxed-out Mac Studios or equivalent GPU rig (~$30k+)**. A single **24 GB** GPU works only for lighter prompts with higher latency. Use the **largest / full-size model variant you can run**; aggressively quantized or “small” checkpoints raise prompt-injection risk (see [Security](/gateway/security)).
If you want the lowest-friction local setup, start with [Ollama](/providers/ollama) and `openclaw setup --wizard`. This page is the opinionated guide for higher-end local stacks and custom OpenAI-compatible local servers.
If you want the lowest-friction local setup, start with [Ollama](/providers/ollama) and `openclaw onboard`. This page is the opinionated guide for higher-end local stacks and custom OpenAI-compatible local servers.
## Recommended: LM Studio + MiniMax M2.5 (Responses API, full-size)

View File

@ -59,7 +59,7 @@ Port spacing: leave at least 20 ports between base ports so the derived browser/
```bash
# Main bot (existing or fresh, without --profile param)
# Runs on port 18789 + Chrome CDC/Canvas/... Ports
openclaw setup --wizard
openclaw onboard
openclaw gateway install
# Rescue bot (isolated profile + ports)

View File

@ -321,7 +321,7 @@ The repo recommends running from source and using the setup wizard:
```bash
curl -fsSL https://openclaw.ai/install.sh | bash
openclaw setup --wizard --install-daemon
openclaw onboard --install-daemon
```
The wizard can also build UI assets automatically. After onboarding, you typically run the Gateway on port **18789**.
@ -334,10 +334,10 @@ cd openclaw
pnpm install
pnpm build
pnpm ui:build # auto-installs UI deps on first run
openclaw setup --wizard
openclaw onboard
```
If you don't have a global install yet, run it via `pnpm openclaw setup --wizard`.
If you don't have a global install yet, run it via `pnpm openclaw onboard`.
### How do I open the dashboard after onboarding
@ -687,7 +687,7 @@ Docs: [Update](/cli/update), [Updating](/install/updating).
### What does the setup wizard actually do
`openclaw setup --wizard` is the recommended setup path. In **local mode** it walks you through:
`openclaw onboard` is the recommended setup path. In **local mode** it walks you through:
- **Model/auth setup** (provider OAuth/setup-token flows and API keys supported, plus local model options such as LM Studio)
- **Workspace** location + bootstrap files
@ -1904,7 +1904,7 @@ openclaw reset --scope full --yes --non-interactive
Then re-run setup:
```bash
openclaw setup --wizard --install-daemon
openclaw onboard --install-daemon
```
Notes:
@ -2092,7 +2092,7 @@ Quickest setup:
1. Install Ollama from `https://ollama.com/download`
2. Pull a local model such as `ollama pull glm-4.7-flash`
3. If you want Ollama Cloud too, run `ollama signin`
4. Run `openclaw setup --wizard` and choose `Ollama`
4. Run `openclaw onboard` and choose `Ollama`
5. Pick `Local` or `Cloud + Local`
Notes:

View File

@ -61,7 +61,7 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- Command: `pnpm test:e2e`
- Config: `vitest.e2e.config.ts`
- Files: `src/**/*.e2e.test.ts`
- Files: `src/**/*.e2e.test.ts`, `test/**/*.e2e.test.ts`
- Runtime defaults:
- Uses Vitest `vmForks` for faster file startup.
- Uses adaptive workers (CI: 2-4, local: 4-8).
@ -77,6 +77,23 @@ Think of the suites as “increasing realism” (and increasing flakiness/cost):
- No real keys required
- More moving parts than unit tests (can be slower)
### E2E: OpenShell backend smoke
- Command: `pnpm test:e2e:openshell`
- File: `test/openshell-sandbox.e2e.test.ts`
- Scope:
- Starts an isolated OpenShell gateway on the host via Docker
- Creates a sandbox from a temporary local Dockerfile
- Exercises OpenClaw's OpenShell backend over real `sandbox ssh-config` + SSH exec
- Verifies remote-canonical filesystem behavior through the sandbox fs bridge
- Expectations:
- Opt-in only; not part of the default `pnpm test:e2e` run
- Requires a local `openshell` CLI plus a working Docker daemon
- Uses isolated `HOME` / `XDG_CONFIG_HOME`, then destroys the test gateway and sandbox
- Useful overrides:
- `OPENCLAW_E2E_OPENSHELL=1` to enable the test when running the broader e2e suite manually
- `OPENCLAW_E2E_OPENSHELL_COMMAND=/path/to/openshell` to point at a non-default CLI binary or wrapper script
### Live (real providers + real models)
- Command: `pnpm test:live`

View File

@ -34,7 +34,7 @@ title: "OpenClaw"
Install OpenClaw and bring up the Gateway in minutes.
</Card>
<Card title="Run the Wizard" href="/start/wizard" icon="sparkles">
Guided setup with `openclaw setup --wizard` and pairing flows.
Guided setup with `openclaw onboard` and pairing flows.
</Card>
<Card title="Open the Control UI" href="/web/control-ui" icon="layout-dashboard">
Launch the browser dashboard for chat, config, and sessions.
@ -103,7 +103,7 @@ The Gateway is the single source of truth for sessions, routing, and channel con
</Step>
<Step title="Onboard and install the service">
```bash
openclaw setup --wizard --install-daemon
openclaw onboard --install-daemon
```
</Step>
<Step title="Pair WhatsApp and start the Gateway">

View File

@ -31,7 +31,7 @@ Shelley, [exe.dev](https://exe.dev)'s agent, can install OpenClaw instantly with
prompt. The prompt used is as below:
```
Set up OpenClaw (https://docs.openclaw.ai/install) on this VM. Use the non-interactive and accept-risk flags for openclaw setup --wizarding. Add the supplied auth or token as needed. Configure nginx to forward from the default port 18789 to the root location on the default enabled site config, making sure to enable Websocket support. Pairing is done by "openclaw devices list" and "openclaw devices approve <request id>". Make sure the dashboard shows that OpenClaw's health is OK. exe.dev handles forwarding from port 8000 to port 80/443 and HTTPS for us, so the final "reachable" should be <vm-name>.exe.xyz, without port specification.
Set up OpenClaw (https://docs.openclaw.ai/install) on this VM. Use the non-interactive and accept-risk flags for openclaw onboarding. Add the supplied auth or token as needed. Configure nginx to forward from the default port 18789 to the root location on the default enabled site config, making sure to enable Websocket support. Pairing is done by "openclaw devices list" and "openclaw devices approve <request id>". Make sure the dashboard shows that OpenClaw's health is OK. exe.dev handles forwarding from port 8000 to port 80/443 and HTTPS for us, so the final "reachable" should be <vm-name>.exe.xyz, without port specification.
```
## Manual installation

View File

@ -76,7 +76,7 @@ For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possibl
<Tab title="npm">
```bash
npm install -g openclaw@latest
openclaw setup --wizard --install-daemon
openclaw onboard --install-daemon
```
<Accordion title="sharp build errors?">
@ -93,7 +93,7 @@ For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possibl
```bash
pnpm add -g openclaw@latest
pnpm approve-builds -g # approve openclaw, node-llama-cpp, sharp, etc.
openclaw setup --wizard --install-daemon
openclaw onboard --install-daemon
```
<Note>
@ -140,7 +140,7 @@ For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possibl
</Step>
<Step title="Run onboarding">
```bash
openclaw setup --wizard --install-daemon
openclaw onboard --install-daemon
```
</Step>
</Steps>

View File

@ -224,7 +224,7 @@ Designed for environments where you want everything under a local prefix (defaul
| `--version <ver>` | OpenClaw version or dist-tag (default: `latest`) |
| `--node-version <ver>` | Node version (default: `22.22.0`) |
| `--json` | Emit NDJSON events |
| `--onboard` | Run `openclaw setup --wizard` after install |
| `--onboard` | Run `openclaw onboard` after install |
| `--no-onboard` | Skip onboarding (default) |
| `--set-npm-prefix` | On Linux, force npm prefix to `~/.npm-global` if current prefix is not writable |
| `--help` | Show usage (`-h`) |

View File

@ -138,7 +138,7 @@ Inside the VM:
```bash
npm install -g openclaw@latest
openclaw setup --wizard --install-daemon
openclaw onboard --install-daemon
```
Follow the onboarding prompts to set up your model provider (Anthropic, OpenAI, etc.).

View File

@ -80,7 +80,7 @@ openclaw --version
## 4) Run Onboarding
```bash
openclaw setup --wizard --install-daemon
openclaw onboard --install-daemon
```
The wizard will walk you through:

View File

@ -42,7 +42,7 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
Use one of these (all supported):
- Wizard (recommended): `openclaw setup --wizard --install-daemon`
- Wizard (recommended): `openclaw onboard --install-daemon`
- Direct: `openclaw gateway install`
- Configure flow: `openclaw configure` → select **Gateway service**
- Repair/migrate: `openclaw doctor` (offers to install or fix the service)

View File

@ -17,7 +17,7 @@ Native Linux companion apps are planned. Contributions are welcome if you want t
1. Install Node 24 (recommended; Node 22 LTS, currently `22.16+`, still works for compatibility)
2. `npm i -g openclaw@latest`
3. `openclaw setup --wizard --install-daemon`
3. `openclaw onboard --install-daemon`
4. From your laptop: `ssh -N -L 18789:127.0.0.1:18789 <user>@<host>`
5. Open `http://127.0.0.1:18789/` and paste your token
@ -39,7 +39,7 @@ Step-by-step VPS guide: [exe.dev](/install/exe-dev)
Use one of these:
```
openclaw setup --wizard --install-daemon
openclaw onboard --install-daemon
```
Or:

View File

@ -130,7 +130,7 @@ The hackable install gives you direct access to logs and code — useful for deb
## 7) Run Onboarding
```bash
openclaw setup --wizard --install-daemon
openclaw onboard --install-daemon
```
Follow the wizard:

View File

@ -38,8 +38,8 @@ openclaw agent --local --agent main --thinking low -m "Reply with exactly WINDOW
Current caveats:
- `openclaw setup --wizard --non-interactive` still expects a reachable local gateway unless you pass `--skip-health`
- `openclaw setup --wizard --non-interactive --install-daemon` and `openclaw gateway install` try Windows Scheduled Tasks first
- `openclaw onboard --non-interactive` still expects a reachable local gateway unless you pass `--skip-health`
- `openclaw onboard --non-interactive --install-daemon` and `openclaw gateway install` try Windows Scheduled Tasks first
- if Scheduled Task creation is denied, OpenClaw falls back to a per-user Startup-folder login item and starts the gateway immediately
- if `schtasks` itself wedges or stops responding, OpenClaw now aborts that path quickly and falls back instead of hanging forever
- Scheduled Tasks are still preferred when available because they provide better supervisor status
@ -47,7 +47,7 @@ Current caveats:
If you want the native CLI only, without gateway service install, use one of these:
```powershell
openclaw setup --wizard --non-interactive --skip-health
openclaw onboard --non-interactive --skip-health
openclaw gateway run
```
@ -70,7 +70,7 @@ If Scheduled Task creation is blocked, the fallback service mode still auto-star
Inside WSL2:
```
openclaw setup --wizard --install-daemon
openclaw onboard --install-daemon
```
Or:
@ -230,7 +230,7 @@ cd openclaw
pnpm install
pnpm ui:build # auto-installs UI deps on first run
pnpm build
openclaw setup --wizard
openclaw onboard
```
Full guide: [Getting Started](/start/getting-started)

View File

@ -19,11 +19,11 @@ Create your API key in the Anthropic Console.
### CLI setup
```bash
openclaw setup --wizard
openclaw onboard
# choose: Anthropic API key
# or non-interactive
openclaw setup --wizard --anthropic-api-key "$ANTHROPIC_API_KEY"
openclaw onboard --anthropic-api-key "$ANTHROPIC_API_KEY"
```
### Config snippet
@ -214,7 +214,7 @@ openclaw models auth paste-token --provider anthropic
```bash
# Paste a setup-token during setup
openclaw setup --wizard --auth-choice setup-token
openclaw onboard --auth-choice setup-token
```
### Config snippet (setup-token)

View File

@ -22,7 +22,7 @@ For Anthropic models, use your Anthropic API key.
1. Set the provider API key and Gateway details:
```bash
openclaw setup --wizard --auth-choice cloudflare-ai-gateway-api-key
openclaw onboard --auth-choice cloudflare-ai-gateway-api-key
```
2. Set a default model:
@ -40,7 +40,7 @@ openclaw setup --wizard --auth-choice cloudflare-ai-gateway-api-key
## Non-interactive example
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--mode local \
--auth-choice cloudflare-ai-gateway-api-key \
--cloudflare-ai-gateway-account-id "your-account-id" \

View File

@ -15,16 +15,16 @@ models are accessed via the `zai` provider and model IDs like `zai/glm-5`.
```bash
# Coding Plan Global, recommended for Coding Plan users
openclaw setup --wizard --auth-choice zai-coding-global
openclaw onboard --auth-choice zai-coding-global
# Coding Plan CN (China region), recommended for Coding Plan users
openclaw setup --wizard --auth-choice zai-coding-cn
openclaw onboard --auth-choice zai-coding-cn
# General API
openclaw setup --wizard --auth-choice zai-global
openclaw onboard --auth-choice zai-global
# General API CN (China region)
openclaw setup --wizard --auth-choice zai-cn
openclaw onboard --auth-choice zai-cn
```
## Config snippet

View File

@ -21,7 +21,7 @@ title: "Hugging Face (Inference)"
2. Run onboarding and choose **Hugging Face** in the provider dropdown, then enter your API key when prompted:
```bash
openclaw setup --wizard --auth-choice huggingface-api-key
openclaw onboard --auth-choice huggingface-api-key
```
3. In the **Default Hugging Face model** dropdown, pick the model you want (the list is loaded from the Inference API when you have a valid token; otherwise a built-in list is shown). Your choice is saved as the default model.
@ -40,7 +40,7 @@ openclaw setup --wizard --auth-choice huggingface-api-key
## Non-interactive example
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--mode local \
--auth-choice huggingface-api-key \
--huggingface-api-key "$HF_TOKEN"

View File

@ -15,7 +15,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
## Quick start
1. Authenticate with the provider (usually via `openclaw setup --wizard`).
1. Authenticate with the provider (usually via `openclaw onboard`).
2. Set the default model:
```json5

View File

@ -19,7 +19,7 @@ endpoint and API key. It is OpenAI-compatible, so most OpenAI SDKs work by switc
## CLI setup
```bash
openclaw setup --wizard --kilocode-api-key <key>
openclaw onboard --kilocode-api-key <key>
```
Or set the environment variable:

View File

@ -22,7 +22,7 @@ read_when:
### Via onboarding
```bash
openclaw setup --wizard --auth-choice litellm-api-key
openclaw onboard --auth-choice litellm-api-key
```
### Manual setup

View File

@ -44,7 +44,7 @@ Enable the bundled OAuth plugin and authenticate:
```bash
openclaw plugins enable minimax # skip if already loaded.
openclaw gateway restart # restart if gateway is already running
openclaw setup --wizard --auth-choice minimax-portal
openclaw onboard --auth-choice minimax-portal
```
You will be prompted to select an endpoint:

View File

@ -15,9 +15,9 @@ Mistral can also be used for memory embeddings (`memorySearch.provider = "mistra
## CLI setup
```bash
openclaw setup --wizard --auth-choice mistral-api-key
openclaw onboard --auth-choice mistral-api-key
# or non-interactive
openclaw setup --wizard --mistral-api-key "$MISTRAL_API_KEY"
openclaw onboard --mistral-api-key "$MISTRAL_API_KEY"
```
## Config snippet (LLM provider)

View File

@ -13,7 +13,7 @@ model as `provider/model`.
## Quick start (two steps)
1. Authenticate with the provider (usually via `openclaw setup --wizard`).
1. Authenticate with the provider (usually via `openclaw onboard`).
2. Set the default model:
```json5

View File

@ -26,13 +26,13 @@ Current Kimi K2 model IDs:
[//]: # "moonshot-kimi-k2-ids:end"
```bash
openclaw setup --wizard --auth-choice moonshot-api-key
openclaw onboard --auth-choice moonshot-api-key
```
Kimi Coding:
```bash
openclaw setup --wizard --auth-choice kimi-code-api-key
openclaw onboard --auth-choice kimi-code-api-key
```
Note: Moonshot and Kimi Coding are separate providers. Keys are not interchangeable, endpoints differ, and model refs differ (Moonshot uses `moonshot/...`, Kimi Coding uses `kimi-coding/...`).

View File

@ -16,7 +16,7 @@ Export the key once, then run onboarding and set an NVIDIA model:
```bash
export NVIDIA_API_KEY="nvapi-..."
openclaw setup --wizard --auth-choice skip
openclaw onboard --auth-choice skip
openclaw models set nvidia/nvidia/llama-3.1-nemotron-70b-instruct
```

View File

@ -21,7 +21,7 @@ Ollama is a local LLM runtime that makes it easy to run open-source models on yo
The fastest way to set up Ollama is through the setup wizard:
```bash
openclaw setup --wizard
openclaw onboard
```
Select **Ollama** from the provider list. The wizard will:
@ -35,7 +35,7 @@ Select **Ollama** from the provider list. The wizard will:
Non-interactive mode is also supported:
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--auth-choice ollama \
--accept-risk
```
@ -43,7 +43,7 @@ openclaw setup --wizard --non-interactive \
Optionally specify a custom base URL or model:
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--auth-choice ollama \
--custom-base-url "http://ollama-host:11434" \
--custom-model-id "qwen3.5:27b" \
@ -73,7 +73,7 @@ ollama signin
4. Run onboarding and choose `Ollama`:
```bash
openclaw setup --wizard
openclaw onboard
```
- `Local`: local models only

View File

@ -20,9 +20,9 @@ Get your API key from the OpenAI dashboard.
### CLI setup
```bash
openclaw setup --wizard --auth-choice openai-api-key
openclaw onboard --auth-choice openai-api-key
# or non-interactive
openclaw setup --wizard --openai-api-key "$OPENAI_API_KEY"
openclaw onboard --openai-api-key "$OPENAI_API_KEY"
```
### Config snippet
@ -52,7 +52,7 @@ Codex cloud requires ChatGPT sign-in, while the Codex CLI supports ChatGPT or AP
```bash
# Run Codex OAuth in the wizard
openclaw setup --wizard --auth-choice openai-codex
openclaw onboard --auth-choice openai-codex
# Or run OAuth directly
openclaw models auth login --provider openai-codex

View File

@ -21,9 +21,9 @@ provider id `opencode-go` so upstream per-model routing stays correct.
## CLI setup
```bash
openclaw setup --wizard --auth-choice opencode-go
openclaw onboard --auth-choice opencode-go
# or non-interactive
openclaw setup --wizard --opencode-go-api-key "$OPENCODE_API_KEY"
openclaw onboard --opencode-go-api-key "$OPENCODE_API_KEY"
```
## Config snippet

View File

@ -22,15 +22,15 @@ as one OpenCode setup.
### Zen catalog
```bash
openclaw setup --wizard --auth-choice opencode-zen
openclaw setup --wizard --opencode-zen-api-key "$OPENCODE_API_KEY"
openclaw onboard --auth-choice opencode-zen
openclaw onboard --opencode-zen-api-key "$OPENCODE_API_KEY"
```
### Go catalog
```bash
openclaw setup --wizard --auth-choice opencode-go
openclaw setup --wizard --opencode-go-api-key "$OPENCODE_API_KEY"
openclaw onboard --auth-choice opencode-go
openclaw onboard --opencode-go-api-key "$OPENCODE_API_KEY"
```
## Config snippet

View File

@ -14,7 +14,7 @@ endpoint and API key. It is OpenAI-compatible, so most OpenAI SDKs work by switc
## CLI setup
```bash
openclaw setup --wizard --auth-choice apiKey --token-provider openrouter --token "$OPENROUTER_API_KEY"
openclaw onboard --auth-choice apiKey --token-provider openrouter --token "$OPENROUTER_API_KEY"
```
## Config snippet

View File

@ -27,7 +27,7 @@ endpoint and API key. It is OpenAI-compatible, so most OpenAI SDKs work by switc
## CLI setup
```bash
openclaw setup --wizard --auth-choice qianfan-api-key
openclaw onboard --auth-choice qianfan-api-key
```
## Related Documentation

View File

@ -33,7 +33,7 @@ export SGLANG_API_KEY="sglang-local"
3. Run onboarding and choose `SGLang`, or set a model directly:
```bash
openclaw setup --wizard
openclaw onboard
```
```json5

View File

@ -17,7 +17,7 @@ Synthetic exposes Anthropic-compatible endpoints. OpenClaw registers it as the
2. Run onboarding:
```bash
openclaw setup --wizard --auth-choice synthetic-api-key
openclaw onboard --auth-choice synthetic-api-key
```
The default model is set to:

View File

@ -18,7 +18,7 @@ The [Together AI](https://together.ai) provides access to leading open-source mo
1. Set the API key (recommended: store it for the Gateway):
```bash
openclaw setup --wizard --auth-choice together-api-key
openclaw onboard --auth-choice together-api-key
```
2. Set a default model:
@ -36,7 +36,7 @@ openclaw setup --wizard --auth-choice together-api-key
## Non-interactive example
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--mode local \
--auth-choice together-api-key \
--together-api-key "$TOGETHER_API_KEY"

View File

@ -58,7 +58,7 @@ export VENICE_API_KEY="vapi_xxxxxxxxxxxx"
**Option B: Interactive Setup (Recommended)**
```bash
openclaw setup --wizard --auth-choice venice-api-key
openclaw onboard --auth-choice venice-api-key
```
This will:
@ -71,7 +71,7 @@ This will:
**Option C: Non-interactive**
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--auth-choice venice-api-key \
--venice-api-key "vapi_xxxxxxxxxxxx"
```

View File

@ -21,7 +21,7 @@ The [Vercel AI Gateway](https://vercel.com/ai-gateway) provides a unified API to
1. Set the API key (recommended: store it for the Gateway):
```bash
openclaw setup --wizard --auth-choice ai-gateway-api-key
openclaw onboard --auth-choice ai-gateway-api-key
```
2. Set a default model:
@ -39,7 +39,7 @@ openclaw setup --wizard --auth-choice ai-gateway-api-key
## Non-interactive example
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--mode local \
--auth-choice ai-gateway-api-key \
--ai-gateway-api-key "$AI_GATEWAY_API_KEY"

View File

@ -22,9 +22,9 @@ the `xiaomi` provider with a Xiaomi MiMo API key.
## CLI setup
```bash
openclaw setup --wizard --auth-choice xiaomi-api-key
openclaw onboard --auth-choice xiaomi-api-key
# or non-interactive
openclaw setup --wizard --auth-choice xiaomi-api-key --xiaomi-api-key "$XIAOMI_API_KEY"
openclaw onboard --auth-choice xiaomi-api-key --xiaomi-api-key "$XIAOMI_API_KEY"
```
## Config snippet

View File

@ -16,16 +16,16 @@ with a Z.AI API key.
```bash
# Coding Plan Global, recommended for Coding Plan users
openclaw setup --wizard --auth-choice zai-coding-global
openclaw onboard --auth-choice zai-coding-global
# Coding Plan CN (China region), recommended for Coding Plan users
openclaw setup --wizard --auth-choice zai-coding-cn
openclaw onboard --auth-choice zai-coding-cn
# General API
openclaw setup --wizard --auth-choice zai-global
openclaw onboard --auth-choice zai-global
# General API CN (China region)
openclaw setup --wizard --auth-choice zai-cn
openclaw onboard --auth-choice zai-cn
```
## Config snippet

View File

@ -223,6 +223,8 @@ channel-specific UX and routing behavior:
- `messaging.enableInteractiveReplies`: channel-owned reply normalization toggles
(for example Slack interactive replies)
- `messaging.resolveOutboundSessionRoute`: channel-owned outbound session routing
- `status.formatCapabilitiesProbe` / `status.buildCapabilitiesDiagnostics`: channel-owned
`/channels capabilities` probe display and extra audits/scopes
- `threading.resolveAutoThreadId`: channel-owned same-conversation auto-threading
- `threading.resolveReplyTransport`: channel-owned reply-vs-thread delivery mapping
- `actions.requiresTrustedRequesterSender`: channel-owned privileged action trust gates

View File

@ -2,7 +2,7 @@
summary: "Full reference for the CLI setup wizard: every step, flag, and config field"
read_when:
- Looking up a specific wizard step or flag
- Automating setup with non-interactive mode
- Automating onboarding with non-interactive mode
- Debugging wizard behavior
title: "Setup Wizard Reference"
sidebarTitle: "Wizard Reference"
@ -10,7 +10,7 @@ sidebarTitle: "Wizard Reference"
# Setup Wizard Reference
This is the full reference for the `openclaw setup --wizard` CLI wizard.
This is the full reference for the `openclaw onboard` CLI wizard.
For a high-level overview, see [Setup Wizard](/start/wizard).
## Flow details (local mode)
@ -76,11 +76,11 @@ For a high-level overview, see [Setup Wizard](/start/wizard).
- In token mode, interactive setup offers:
- **Generate/store plaintext token** (default)
- **Use SecretRef** (opt-in)
- Quickstart reuses existing `gateway.auth.token` SecretRefs across `env`, `file`, and `exec` providers for setup probe/dashboard bootstrap.
- If that SecretRef is configured but cannot be resolved, setup fails early with a clear fix message instead of silently degrading runtime auth.
- Quickstart reuses existing `gateway.auth.token` SecretRefs across `env`, `file`, and `exec` providers for onboarding probe/dashboard bootstrap.
- If that SecretRef is configured but cannot be resolved, onboarding fails early with a clear fix message instead of silently degrading runtime auth.
- In password mode, interactive setup also supports plaintext or SecretRef storage.
- Non-interactive token SecretRef path: `--gateway-token-ref-env <ENV_VAR>`.
- Requires a non-empty env var in the setup process environment.
- Requires a non-empty env var in the onboarding process environment.
- Cannot be combined with `--gateway-token`.
- Disable auth only if you fully trust every local process.
- Nonloopback binds still require auth.
@ -137,7 +137,7 @@ If the Control UI assets are missing, the wizard attempts to build them; fallbac
Use `--non-interactive` to automate or script onboarding:
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--mode local \
--auth-choice apiKey \
--anthropic-api-key "$ANTHROPIC_API_KEY" \
@ -154,7 +154,7 @@ Gateway token SecretRef in non-interactive mode:
```bash
export OPENCLAW_GATEWAY_TOKEN="your-token"
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--mode local \
--auth-choice skip \
--gateway-auth token \

View File

@ -54,7 +54,7 @@ Check your Node version with `node --version` if you are unsure.
</Step>
<Step title="Run the setup wizard">
```bash
openclaw setup --wizard --install-daemon
openclaw onboard --install-daemon
```
The wizard configures auth, gateway settings, and optional channels.

View File

@ -1,18 +1,18 @@
---
summary: "Overview of OpenClaw setup options and flows"
summary: "Overview of OpenClaw onboarding options and flows"
read_when:
- Choosing a setup path
- Choosing an onboarding path
- Setting up a new environment
title: "Setup Overview"
sidebarTitle: "Setup Overview"
title: "Onboarding Overview"
sidebarTitle: "Onboarding Overview"
---
# Setup Overview
# Onboarding Overview
OpenClaw supports multiple setup paths depending on where the Gateway runs
OpenClaw supports multiple onboarding paths depending on where the Gateway runs
and how you prefer to configure providers.
## Choose your setup path
## Choose your onboarding path
- **CLI wizard** for macOS, Linux, and Windows (via WSL2).
- **macOS app** for a guided first run on Apple silicon or Intel Macs.
@ -22,14 +22,14 @@ and how you prefer to configure providers.
Run the wizard in a terminal:
```bash
openclaw setup --wizard
openclaw onboard
```
Use the CLI wizard when you want full control of the Gateway, workspace,
channels, and skills. Docs:
- [Setup Wizard (CLI)](/start/wizard)
- [`openclaw setup --wizard` command](/cli/setup)
- [`openclaw onboard` command](/cli/onboard)
## macOS app onboarding
@ -48,4 +48,4 @@ CLI wizard. You will be asked to:
- Provide a model ID and optional alias.
- Choose an Endpoint ID so multiple custom endpoints can coexist.
For detailed steps, follow the CLI setup docs above.
For detailed steps, follow the CLI onboarding docs above.

View File

@ -1,7 +1,7 @@
---
summary: "Scripted setup wizard and agent setup for the OpenClaw CLI"
summary: "Scripted onboarding and agent setup for the OpenClaw CLI"
read_when:
- You are automating setup in scripts or CI
- You are automating onboarding in scripts or CI
- You need non-interactive examples for specific providers
title: "CLI Automation"
sidebarTitle: "CLI automation"
@ -9,7 +9,7 @@ sidebarTitle: "CLI automation"
# CLI Automation
Use `--non-interactive` to automate `openclaw setup --wizard`.
Use `--non-interactive` to automate `openclaw onboard`.
<Note>
`--json` does not imply non-interactive mode. Use `--non-interactive` (and `--workspace`) for scripts.
@ -18,7 +18,7 @@ Use `--non-interactive` to automate `openclaw setup --wizard`.
## Baseline non-interactive example
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--mode local \
--auth-choice apiKey \
--anthropic-api-key "$ANTHROPIC_API_KEY" \
@ -41,7 +41,7 @@ Passing inline key flags without the matching env var now fails fast.
Example:
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--mode local \
--auth-choice openai-api-key \
--secret-input-mode ref \
@ -53,7 +53,7 @@ openclaw setup --wizard --non-interactive \
<AccordionGroup>
<Accordion title="Gemini example">
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--mode local \
--auth-choice gemini-api-key \
--gemini-api-key "$GEMINI_API_KEY" \
@ -63,7 +63,7 @@ openclaw setup --wizard --non-interactive \
</Accordion>
<Accordion title="Z.AI example">
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--mode local \
--auth-choice zai-api-key \
--zai-api-key "$ZAI_API_KEY" \
@ -73,7 +73,7 @@ openclaw setup --wizard --non-interactive \
</Accordion>
<Accordion title="Vercel AI Gateway example">
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--mode local \
--auth-choice ai-gateway-api-key \
--ai-gateway-api-key "$AI_GATEWAY_API_KEY" \
@ -83,7 +83,7 @@ openclaw setup --wizard --non-interactive \
</Accordion>
<Accordion title="Cloudflare AI Gateway example">
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--mode local \
--auth-choice cloudflare-ai-gateway-api-key \
--cloudflare-ai-gateway-account-id "your-account-id" \
@ -95,7 +95,7 @@ openclaw setup --wizard --non-interactive \
</Accordion>
<Accordion title="Moonshot example">
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--mode local \
--auth-choice moonshot-api-key \
--moonshot-api-key "$MOONSHOT_API_KEY" \
@ -105,7 +105,7 @@ openclaw setup --wizard --non-interactive \
</Accordion>
<Accordion title="Mistral example">
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--mode local \
--auth-choice mistral-api-key \
--mistral-api-key "$MISTRAL_API_KEY" \
@ -115,7 +115,7 @@ openclaw setup --wizard --non-interactive \
</Accordion>
<Accordion title="Synthetic example">
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--mode local \
--auth-choice synthetic-api-key \
--synthetic-api-key "$SYNTHETIC_API_KEY" \
@ -125,7 +125,7 @@ openclaw setup --wizard --non-interactive \
</Accordion>
<Accordion title="OpenCode example">
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--mode local \
--auth-choice opencode-zen \
--opencode-zen-api-key "$OPENCODE_API_KEY" \
@ -136,7 +136,7 @@ openclaw setup --wizard --non-interactive \
</Accordion>
<Accordion title="Ollama example">
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--mode local \
--auth-choice ollama \
--custom-model-id "qwen3.5:27b" \
@ -147,7 +147,7 @@ openclaw setup --wizard --non-interactive \
</Accordion>
<Accordion title="Custom provider example">
```bash
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--mode local \
--auth-choice custom-api-key \
--custom-base-url "https://llm.example.com/v1" \
@ -165,7 +165,7 @@ openclaw setup --wizard --non-interactive \
```bash
export CUSTOM_API_KEY="your-key"
openclaw setup --wizard --non-interactive \
openclaw onboard --non-interactive \
--mode local \
--auth-choice custom-api-key \
--custom-base-url "https://llm.example.com/v1" \
@ -212,4 +212,4 @@ Notes:
- Onboarding hub: [Setup Wizard (CLI)](/start/wizard)
- Full reference: [CLI Setup Reference](/start/wizard-cli-reference)
- Command reference: [`openclaw setup --wizard`](/cli/setup)
- Command reference: [`openclaw onboard`](/cli/onboard)

View File

@ -1,15 +1,15 @@
---
summary: "Complete reference for CLI setup flow, auth/model setup, outputs, and internals"
read_when:
- You need detailed behavior for `openclaw setup --wizard`
- You are debugging setup results or integrating setup clients
- You need detailed behavior for openclaw onboard
- You are debugging onboarding results or integrating onboarding clients
title: "CLI Setup Reference"
sidebarTitle: "CLI reference"
---
# CLI Setup Reference
This page is the full reference for `openclaw setup --wizard`.
This page is the full reference for `openclaw onboard`.
For the short guide, see [Setup Wizard (CLI)](/start/wizard).
## What the wizard does
@ -56,7 +56,7 @@ It does not install or modify anything on the remote host.
- **Use SecretRef** (opt-in)
- In password mode, interactive setup also supports plaintext or SecretRef storage.
- Non-interactive token SecretRef path: `--gateway-token-ref-env <ENV_VAR>`.
- Requires a non-empty env var in the setup process environment.
- Requires a non-empty env var in the onboarding process environment.
- Cannot be combined with `--gateway-token`.
- Disable auth only if you fully trust every local process.
- Non-loopback binds still require auth.
@ -220,20 +220,20 @@ Credential and profile paths:
Credential storage mode:
- Default setup behavior persists API keys as plaintext values in auth profiles.
- Default onboarding behavior persists API keys as plaintext values in auth profiles.
- `--secret-input-mode ref` enables reference mode instead of plaintext key storage.
In interactive setup, you can choose either:
- environment variable ref (for example `keyRef: { source: "env", provider: "default", id: "OPENAI_API_KEY" }`)
- configured provider ref (`file` or `exec`) with provider alias + id
- Interactive reference mode runs a fast preflight validation before saving.
- Env refs: validates variable name + non-empty value in the current setup environment.
- Env refs: validates variable name + non-empty value in the current onboarding environment.
- Provider refs: validates provider config and resolves the requested id.
- If preflight fails, setup shows the error and lets you retry.
- If preflight fails, onboarding shows the error and lets you retry.
- In non-interactive mode, `--secret-input-mode ref` is env-backed only.
- Set the provider env var in the setup process environment.
- Inline key flags (for example `--openai-api-key`) require that env var to be set; otherwise setup fails fast.
- Set the provider env var in the onboarding process environment.
- Inline key flags (for example `--openai-api-key`) require that env var to be set; otherwise onboarding fails fast.
- For custom providers, non-interactive `ref` mode stores `models.providers.<id>.apiKey` as `{ source: "env", provider: "default", id: "CUSTOM_API_KEY" }`.
- In that custom-provider case, `--custom-api-key` requires `CUSTOM_API_KEY` to be set; otherwise setup fails fast.
- In that custom-provider case, `--custom-api-key` requires `CUSTOM_API_KEY` to be set; otherwise onboarding fails fast.
- Gateway auth credentials support plaintext and SecretRef choices in interactive setup:
- Token mode: **Generate/store plaintext token** (default) or **Use SecretRef**.
- Password mode: plaintext or SecretRef.
@ -252,9 +252,9 @@ Typical fields in `~/.openclaw/openclaw.json`:
- `agents.defaults.workspace`
- `agents.defaults.model` / `models.providers` (if Minimax chosen)
- `tools.profile` (local setup defaults to `"coding"` when unset; existing explicit values are preserved)
- `tools.profile` (local onboarding defaults to `"coding"` when unset; existing explicit values are preserved)
- `gateway.*` (mode, bind, auth, tailscale)
- `session.dmScope` (local setup defaults this to `per-channel-peer` when unset; existing explicit values are preserved)
- `session.dmScope` (local onboarding defaults this to `per-channel-peer` when unset; existing explicit values are preserved)
- `channels.telegram.botToken`, `channels.discord.token`, `channels.signal.*`, `channels.imessage.*`
- Channel allowlists (Slack, Discord, Matrix, Microsoft Teams) when you opt in during prompts (names resolve to IDs when possible)
- `skills.install.nodeManager`
@ -296,4 +296,4 @@ Signal setup behavior:
- Onboarding hub: [Setup Wizard (CLI)](/start/wizard)
- Automation and scripts: [CLI Automation](/start/wizard-cli-automation)
- Command reference: [`openclaw setup --wizard`](/cli/setup)
- Command reference: [`openclaw onboard`](/cli/onboard)

View File

@ -4,7 +4,7 @@ read_when:
- Running or configuring the setup wizard
- Setting up a new machine
title: "Setup Wizard (CLI)"
sidebarTitle: "Setup: CLI"
sidebarTitle: "Onboarding: CLI"
---
# Setup Wizard (CLI)
@ -15,7 +15,7 @@ It configures a local Gateway or a remote Gateway connection, plus channels, ski
and workspace defaults in one guided flow.
```bash
openclaw setup --wizard
openclaw onboard
```
<Info>
@ -52,7 +52,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
- Gateway port **18789**
- Gateway auth **Token** (autogenerated, even on loopback)
- Tool policy default for new local setups: `tools.profile: "coding"` (existing explicit profile is preserved)
- DM isolation default: local setup writes `session.dmScope: "per-channel-peer"` when unset. Details: [CLI Setup Reference](/start/wizard-cli-reference#outputs-and-internals)
- DM isolation default: local onboarding writes `session.dmScope: "per-channel-peer"` when unset. Details: [CLI Setup Reference](/start/wizard-cli-reference#outputs-and-internals)
- Tailscale exposure **Off**
- Telegram + WhatsApp DMs default to **allowlist** (you'll be prompted for your phone number)
</Tab>
@ -119,7 +119,7 @@ For the deeper technical reference, including RPC details, see
## Related docs
- CLI command reference: [`openclaw setup`](/cli/setup)
- Setup overview: [Setup Overview](/start/onboarding-overview)
- CLI command reference: [`openclaw onboard`](/cli/onboard)
- Onboarding overview: [Onboarding Overview](/start/onboarding-overview)
- macOS app onboarding: [Onboarding](/start/onboarding)
- Agent first-run ritual: [Agent Bootstrapping](/start/bootstrapping)

View File

@ -1195,11 +1195,11 @@ A provider plugin can participate in five distinct phases:
`auth[].run(ctx)` performs OAuth, API-key capture, device code, or custom
setup and returns auth profiles plus optional config patches.
2. **Non-interactive setup**
`auth[].runNonInteractive(ctx)` handles `openclaw setup --wizard --non-interactive`
`auth[].runNonInteractive(ctx)` handles `openclaw onboard --non-interactive`
without prompts. Use this when the provider needs custom headless setup
beyond the built-in simple API-key paths.
3. **Wizard integration**
`wizard.setup` adds an entry to `openclaw setup --wizard`.
`wizard.setup` adds an entry to `openclaw onboard`.
`wizard.modelPicker` adds a setup entry to the model picker.
4. **Implicit discovery**
`discovery.run(ctx)` can contribute provider config automatically during
@ -1275,6 +1275,7 @@ errors instead.
- `groupLabel`: group label
- `groupHint`: group hint
- `methodId`: auth method to run
- `modelAllowlist`: optional post-auth allowlist policy (`allowedKeys`, `initialSelections`, `message`)
`wizard.modelPicker` controls how a provider appears as a "set this up now"
entry in model selection:
@ -1360,7 +1361,7 @@ or more auth methods (OAuth, API key, device code, etc.). Those methods can
power:
- `openclaw models auth login --provider <id> [--method <id>]`
- `openclaw setup --wizard`
- `openclaw onboard`
- model-picker “custom provider” setup entries
- implicit provider discovery during model resolution/listing
@ -1435,8 +1436,13 @@ Notes:
for headless onboarding.
- Return `configPatch` when you need to add default models or provider config.
- Return `defaultModel` so `--set-default` can update agent defaults.
- `wizard.setup` adds a provider choice to `openclaw setup --wizard`.
- `wizard.setup` adds a provider choice to onboarding surfaces such as
`openclaw onboard` / `openclaw setup --wizard`.
- `wizard.setup.modelAllowlist` lets the provider narrow the follow-up model
allowlist prompt during onboarding/configure.
- `wizard.modelPicker` adds a “setup this provider” entry to the model picker.
- `deprecatedProfileIds` lets the provider own `openclaw doctor` cleanup for
retired auth-profile ids.
- `discovery.run` returns either `{ provider }` for the plugins own provider id
or `{ providers }` for multi-provider discovery.
- `discovery.order` controls when the provider runs relative to built-in

View File

@ -5,7 +5,11 @@ import {
type ProviderResolveDynamicModelContext,
type ProviderRuntimeModel,
} from "openclaw/plugin-sdk/core";
import { listProfilesForProvider, upsertAuthProfile } from "../../src/agents/auth-profiles.js";
import {
CLAUDE_CLI_PROFILE_ID,
listProfilesForProvider,
upsertAuthProfile,
} from "../../src/agents/auth-profiles.js";
import { suggestOAuthProfileIdForLegacyDefault } from "../../src/agents/auth-profiles/repair.js";
import type { AuthProfileStore } from "../../src/agents/auth-profiles/types.js";
import { normalizeModelCompat } from "../../src/agents/model-compat.js";
@ -19,10 +23,12 @@ import {
import { buildTokenProfileId, validateAnthropicSetupToken } from "../../src/commands/auth-token.js";
import { applyAuthProfileConfig } from "../../src/commands/onboard-auth.js";
import { fetchClaudeUsage } from "../../src/infra/provider-usage.fetch.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import type { ProviderAuthResult } from "../../src/plugins/types.js";
import { normalizeSecretInput } from "../../src/utils/normalize-secret-input.js";
const PROVIDER_ID = "anthropic";
const DEFAULT_ANTHROPIC_MODEL = "anthropic/claude-sonnet-4-6";
const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6";
const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6";
const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const;
@ -36,6 +42,13 @@ const ANTHROPIC_MODERN_MODEL_PREFIXES = [
"claude-sonnet-4-5",
"claude-haiku-4-5",
] as const;
const ANTHROPIC_OAUTH_ALLOWLIST = [
"anthropic/claude-sonnet-4-6",
"anthropic/claude-opus-4-6",
"anthropic/claude-opus-4-5",
"anthropic/claude-sonnet-4-5",
"anthropic/claude-haiku-4-5",
] as const;
function cloneFirstTemplateModel(params: {
modelId: string;
@ -307,12 +320,26 @@ const anthropicPlugin = {
label: "Anthropic",
docsPath: "/providers/models",
envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
deprecatedProfileIds: [CLAUDE_CLI_PROFILE_ID],
auth: [
{
id: "setup-token",
label: "setup-token (claude)",
hint: "Paste a setup-token from `claude setup-token`",
kind: "token",
wizard: {
choiceId: "token",
choiceLabel: "Anthropic token (paste setup-token)",
choiceHint: "Run `claude setup-token` elsewhere, then paste the token here",
groupId: "anthropic",
groupLabel: "Anthropic",
groupHint: "setup-token + API key",
modelAllowlist: {
allowedKeys: [...ANTHROPIC_OAUTH_ALLOWLIST],
initialSelections: ["anthropic/claude-sonnet-4-6"],
message: "Anthropic OAuth models",
},
},
run: async (ctx: ProviderAuthContext) => await runAnthropicSetupToken(ctx),
runNonInteractive: async (ctx) =>
await runAnthropicSetupTokenNonInteractive({
@ -322,15 +349,26 @@ const anthropicPlugin = {
agentDir: ctx.agentDir,
}),
},
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "Anthropic API key",
hint: "Direct Anthropic API key",
optionKey: "anthropicApiKey",
flagName: "--anthropic-api-key",
envVar: "ANTHROPIC_API_KEY",
promptMessage: "Enter Anthropic API key",
defaultModel: DEFAULT_ANTHROPIC_MODEL,
expectedProviders: ["anthropic"],
wizard: {
choiceId: "apiKey",
choiceLabel: "Anthropic API key",
groupId: "anthropic",
groupLabel: "Anthropic",
groupHint: "setup-token + API key",
},
}),
],
wizard: {
setup: {
choiceId: "token",
choiceLabel: "Anthropic token (paste setup-token)",
choiceHint: "Run `claude setup-token` elsewhere, then paste the token here",
methodId: "setup-token",
},
},
resolveDynamicModel: (ctx) => resolveAnthropicForwardCompatModel(ctx),
capabilities: {
providerFamily: "anthropic",

View File

@ -10,7 +10,7 @@ import { resolveReactionMessageId } from "../../../../src/channels/plugins/actio
import type { ChannelMessageActionContext } from "../../../../src/channels/plugins/types.js";
import { normalizeInteractiveReply } from "../../../../src/interactive/payload.js";
import { readBooleanParam } from "../../../../src/plugin-sdk/boolean-param.js";
import { buildDiscordInteractiveComponents } from "../components.js";
import { buildDiscordInteractiveComponents } from "../shared-interactive.js";
import { resolveDiscordChannelId } from "../targets.js";
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";

View File

@ -38,8 +38,11 @@ import {
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
import { normalizeMessageChannel } from "../../../src/utils/message-channel.js";
import { isDiscordExecApprovalClientEnabled } from "./exec-approvals.js";
import type { DiscordProbe } from "./probe.js";
import { getDiscordRuntime } from "./runtime.js";
import { fetchChannelPermissionsDiscord } from "./send.js";
import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js";
import { parseDiscordTarget } from "./targets.js";
import { DiscordUiContainer } from "./ui.js";
type DiscordSendFn = ReturnType<
@ -47,11 +50,27 @@ type DiscordSendFn = ReturnType<
>["channel"]["discord"]["sendMessageDiscord"];
const meta = getChatChannelMeta("discord");
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
async function loadDiscordChannelRuntime() {
return await import("./channel.runtime.js");
}
function formatDiscordIntents(intents?: {
messageContent?: string;
guildMembers?: string;
presence?: string;
}) {
if (!intents) {
return "unknown";
}
return [
`messageContent=${intents.messageContent ?? "unknown"}`,
`guildMembers=${intents.guildMembers ?? "unknown"}`,
`presence=${intents.presence ?? "unknown"}`,
].join(" ");
}
const discordMessageActions: ChannelMessageActionAdapter = {
listActions: (ctx) =>
getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [],
@ -355,6 +374,91 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
getDiscordRuntime().channel.discord.probeDiscord(account.token, timeoutMs, {
includeApplication: true,
}),
formatCapabilitiesProbe: ({ probe }) => {
const discordProbe = probe as DiscordProbe | undefined;
const lines = [];
if (discordProbe?.bot?.username) {
const botId = discordProbe.bot.id ? ` (${discordProbe.bot.id})` : "";
lines.push({ text: `Bot: @${discordProbe.bot.username}${botId}` });
}
if (discordProbe?.application?.intents) {
lines.push({ text: `Intents: ${formatDiscordIntents(discordProbe.application.intents)}` });
}
return lines;
},
buildCapabilitiesDiagnostics: async ({ account, timeoutMs, target }) => {
if (!target?.trim()) {
return undefined;
}
const parsedTarget = parseDiscordTarget(target.trim(), { defaultKind: "channel" });
const details: Record<string, unknown> = {
target: {
raw: target,
normalized: parsedTarget?.normalized,
kind: parsedTarget?.kind,
channelId: parsedTarget?.kind === "channel" ? parsedTarget.id : undefined,
},
};
if (!parsedTarget || parsedTarget.kind !== "channel") {
return {
details,
lines: [
{
text: "Permissions: Target looks like a DM user; pass channel:<id> to audit channel permissions.",
tone: "error",
},
],
};
}
const token = account.token?.trim();
if (!token) {
return {
details,
lines: [
{
text: "Permissions: Discord bot token missing for permission audit.",
tone: "error",
},
],
};
}
try {
const perms = await fetchChannelPermissionsDiscord(parsedTarget.id, {
token,
accountId: account.accountId ?? undefined,
});
const missingRequired = REQUIRED_DISCORD_PERMISSIONS.filter(
(permission) => !perms.permissions.includes(permission),
);
details.permissions = {
channelId: perms.channelId,
guildId: perms.guildId,
isDm: perms.isDm,
channelType: perms.channelType,
permissions: perms.permissions,
missingRequired,
raw: perms.raw,
};
return {
details,
lines: [
{
text: `Permissions (${perms.channelId}): ${perms.permissions.length ? perms.permissions.join(", ") : "none"}`,
},
missingRequired.length > 0
? { text: `Missing required: ${missingRequired.join(", ")}`, tone: "warn" }
: { text: "Missing required: none", tone: "success" },
],
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
details.permissions = { channelId: parsedTarget.id, error: message };
return {
details,
lines: [{ text: `Permissions: ${message}`, tone: "error" }],
};
}
},
auditAccount: async ({ account, timeoutMs, cfg }) => {
const { channelIds, unresolvedChannels } = collectDiscordAuditChannelIds({
cfg,

View File

@ -25,8 +25,6 @@ import {
type TopLevelComponents,
} from "@buape/carbon";
import { ButtonStyle, MessageFlags, TextInputStyle } from "discord-api-types/v10";
import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js";
import type { InteractiveButtonStyle, InteractiveReply } from "../../../src/interactive/payload.js";
export const DISCORD_COMPONENT_CUSTOM_ID_KEY = "occomp";
export const DISCORD_MODAL_CUSTOM_ID_KEY = "ocmodal";
@ -213,69 +211,7 @@ export type DiscordComponentBuildResult = {
entries: DiscordComponentEntry[];
modals: DiscordModalEntry[];
};
function resolveDiscordInteractiveButtonStyle(
style?: InteractiveButtonStyle,
): DiscordComponentButtonStyle | undefined {
return style ?? "secondary";
}
const DISCORD_INTERACTIVE_BUTTON_ROW_SIZE = 5;
export function buildDiscordInteractiveComponents(
interactive?: InteractiveReply,
): DiscordComponentMessageSpec | undefined {
const blocks = reduceInteractiveReply(
interactive,
[] as NonNullable<DiscordComponentMessageSpec["blocks"]>,
(state, block) => {
if (block.type === "text") {
const text = block.text.trim();
if (text) {
state.push({ type: "text", text });
}
return state;
}
if (block.type === "buttons") {
if (block.buttons.length === 0) {
return state;
}
for (
let index = 0;
index < block.buttons.length;
index += DISCORD_INTERACTIVE_BUTTON_ROW_SIZE
) {
state.push({
type: "actions",
buttons: block.buttons
.slice(index, index + DISCORD_INTERACTIVE_BUTTON_ROW_SIZE)
.map((button) => ({
label: button.label,
style: resolveDiscordInteractiveButtonStyle(button.style),
callbackData: button.value,
})),
});
}
return state;
}
if (block.type === "select" && block.options.length > 0) {
state.push({
type: "actions",
select: {
type: "string",
placeholder: block.placeholder,
options: block.options.map((option) => ({
label: option.label,
value: option.value,
})),
},
});
}
return state;
},
);
return blocks.length > 0 ? { blocks } : undefined;
}
export { buildDiscordInteractiveComponents } from "./shared-interactive.js";
const BLOCK_ALIASES = new Map<string, DiscordComponentBlock["type"]>([
["row", "actions"],

View File

@ -8,7 +8,6 @@ import type { OpenClawConfig } from "../../../src/config/config.js";
import type { OutboundIdentity } from "../../../src/infra/outbound/identity.js";
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
import type { DiscordComponentMessageSpec } from "./components.js";
import { buildDiscordInteractiveComponents } from "./components.js";
import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js";
import { normalizeDiscordOutboundTarget } from "./normalize.js";
import {
@ -17,6 +16,7 @@ import {
sendPollDiscord,
sendWebhookMessageDiscord,
} from "./send.js";
import { buildDiscordInteractiveComponents } from "./shared-interactive.js";
function resolveDiscordOutboundTarget(params: {
to: string;

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { buildDiscordInteractiveComponents } from "./components.js";
import { buildDiscordInteractiveComponents } from "./shared-interactive.js";
describe("buildDiscordInteractiveComponents", () => {
it("maps shared buttons and selects into Discord component blocks", () => {

View File

@ -0,0 +1,66 @@
import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js";
import type { InteractiveButtonStyle, InteractiveReply } from "../../../src/interactive/payload.js";
import type { DiscordComponentButtonStyle, DiscordComponentMessageSpec } from "./components.js";
function resolveDiscordInteractiveButtonStyle(
style?: InteractiveButtonStyle,
): DiscordComponentButtonStyle | undefined {
return style ?? "secondary";
}
const DISCORD_INTERACTIVE_BUTTON_ROW_SIZE = 5;
export function buildDiscordInteractiveComponents(
interactive?: InteractiveReply,
): DiscordComponentMessageSpec | undefined {
const blocks = reduceInteractiveReply(
interactive,
[] as NonNullable<DiscordComponentMessageSpec["blocks"]>,
(state, block) => {
if (block.type === "text") {
const text = block.text.trim();
if (text) {
state.push({ type: "text", text });
}
return state;
}
if (block.type === "buttons") {
if (block.buttons.length === 0) {
return state;
}
for (
let index = 0;
index < block.buttons.length;
index += DISCORD_INTERACTIVE_BUTTON_ROW_SIZE
) {
state.push({
type: "actions",
buttons: block.buttons
.slice(index, index + DISCORD_INTERACTIVE_BUTTON_ROW_SIZE)
.map((button) => ({
label: button.label,
style: resolveDiscordInteractiveButtonStyle(button.style),
callbackData: button.value,
})),
});
}
return state;
}
if (block.type === "select" && block.options.length > 0) {
state.push({
type: "actions",
select: {
type: "string",
placeholder: block.placeholder,
options: block.options.map((option) => ({
label: option.label,
value: option.value,
})),
},
});
}
return state;
},
);
return blocks.length > 0 ? { blocks } : undefined;
}

View File

@ -1,5 +1,15 @@
import { describe, it, expect, vi } from "vitest";
import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js";
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
handleFeishuCardAction,
resetProcessedFeishuCardActionTokensForTests,
type FeishuCardActionEvent,
} from "./card-action.js";
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
import {
FEISHU_APPROVAL_CANCEL_ACTION,
FEISHU_APPROVAL_CONFIRM_ACTION,
FEISHU_APPROVAL_REQUEST_ACTION,
} from "./card-ux-approval.js";
// Mock resolveFeishuAccount
vi.mock("./accounts.js", () => ({
@ -11,12 +21,25 @@ vi.mock("./bot.js", () => ({
handleFeishuMessage: vi.fn(),
}));
const sendCardFeishuMock = vi.hoisted(() => vi.fn());
const sendMessageFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./send.js", () => ({
sendCardFeishu: sendCardFeishuMock,
sendMessageFeishu: sendMessageFeishuMock,
}));
import { handleFeishuMessage } from "./bot.js";
describe("Feishu Card Action Handler", () => {
const cfg = {} as any; // Minimal mock
const runtime = { log: vi.fn(), error: vi.fn() } as any;
beforeEach(() => {
vi.clearAllMocks();
resetProcessedFeishuCardActionTokensForTests();
});
it("handles card action with text payload", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
@ -60,4 +83,321 @@ describe("Feishu Card Action Handler", () => {
}),
);
});
it("routes quick command actions with operator and conversation context", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok3",
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
sender: expect.objectContaining({
sender_id: expect.objectContaining({
open_id: "u123",
user_id: "uid1",
union_id: "un1",
}),
}),
message: expect.objectContaining({
chat_id: "chat1",
content: '{"text":"/help"}',
}),
}),
}),
);
});
it("opens an approval card for metadata actions", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok4",
action: {
value: createFeishuCardInteractionEnvelope({
k: "meta",
a: FEISHU_APPROVAL_REQUEST_ACTION,
m: {
command: "/new",
prompt: "Start a fresh session?",
},
c: {
u: "u123",
h: "chat1",
t: "group",
s: "agent:codex:feishu:chat:chat1",
e: Date.now() + 60_000,
},
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime, accountId: "main" });
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:chat1",
accountId: "main",
card: expect.objectContaining({
header: expect.objectContaining({
title: expect.objectContaining({ content: "Confirm action" }),
}),
body: expect.objectContaining({
elements: expect.arrayContaining([
expect.objectContaining({
tag: "action",
actions: expect.arrayContaining([
expect.objectContaining({
value: expect.objectContaining({
c: expect.objectContaining({
u: "u123",
h: "chat1",
t: "group",
s: "agent:codex:feishu:chat:chat1",
}),
}),
}),
]),
}),
]),
}),
}),
}),
);
expect(handleFeishuMessage).not.toHaveBeenCalled();
});
it("runs approval confirmation through the normal message path", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok5",
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: FEISHU_APPROVAL_CONFIRM_ACTION,
q: "/new",
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
content: '{"text":"/new"}',
}),
}),
}),
);
});
it("safely rejects stale structured actions", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok6",
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "chat1", t: "group", e: Date.now() - 1 },
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime });
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:chat1",
text: expect.stringContaining("expired"),
}),
);
expect(handleFeishuMessage).not.toHaveBeenCalled();
});
it("safely rejects wrong-user structured actions", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u999", user_id: "uid1", union_id: "un1" },
token: "tok7",
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
}),
tag: "button",
},
context: { open_id: "u999", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime });
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
text: expect.stringContaining("different user"),
}),
);
expect(handleFeishuMessage).not.toHaveBeenCalled();
});
it("sends a lightweight cancellation notice", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok8",
action: {
value: createFeishuCardInteractionEnvelope({
k: "button",
a: FEISHU_APPROVAL_CANCEL_ACTION,
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime });
expect(sendMessageFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:chat1",
text: "Cancelled.",
}),
);
});
it("preserves p2p callbacks for DM quick actions", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok9",
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "p2p-chat-1", t: "p2p", e: Date.now() + 60_000 },
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "p2p-chat-1" },
};
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
chat_id: "p2p-chat-1",
chat_type: "p2p",
}),
}),
}),
);
});
it("drops duplicate structured callback tokens", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok10",
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
await handleFeishuCardAction({ cfg, event, runtime });
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledTimes(1);
});
it("releases a claimed token when dispatch fails so retries can succeed", async () => {
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok11",
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
vi.mocked(handleFeishuMessage)
.mockRejectedValueOnce(new Error("transient"))
.mockResolvedValueOnce(undefined as never);
await expect(handleFeishuCardAction({ cfg, event, runtime })).rejects.toThrow("transient");
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledTimes(2);
});
it("keeps an in-flight token claimed while a slow dispatch is still running", async () => {
vi.useFakeTimers();
const event: FeishuCardActionEvent = {
operator: { open_id: "u123", user_id: "uid1", union_id: "un1" },
token: "tok12",
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
}),
tag: "button",
},
context: { open_id: "u123", user_id: "uid1", chat_id: "chat1" },
};
let resolveDispatch: (() => void) | undefined;
vi.mocked(handleFeishuMessage).mockImplementation(
() =>
new Promise<void>((resolve) => {
resolveDispatch = resolve;
}) as never,
);
const first = handleFeishuCardAction({ cfg, event, runtime });
await vi.advanceTimersByTimeAsync(61_000);
await handleFeishuCardAction({ cfg, event, runtime });
expect(handleFeishuMessage).toHaveBeenCalledTimes(1);
resolveDispatch?.();
await first;
vi.useRealTimers();
});
});

View File

@ -1,6 +1,14 @@
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
import { resolveFeishuAccount } from "./accounts.js";
import { handleFeishuMessage, type FeishuMessageEvent } from "./bot.js";
import { decodeFeishuCardAction, buildFeishuCardActionTextFallback } from "./card-interaction.js";
import {
createApprovalCard,
FEISHU_APPROVAL_CANCEL_ACTION,
FEISHU_APPROVAL_CONFIRM_ACTION,
FEISHU_APPROVAL_REQUEST_ACTION,
} from "./card-ux-approval.js";
import { sendCardFeishu, sendMessageFeishu } from "./send.js";
export type FeishuCardActionEvent = {
operator: {
@ -20,18 +28,142 @@ export type FeishuCardActionEvent = {
};
};
function buildCardActionTextFallback(event: FeishuCardActionEvent): string {
const actionValue = event.action.value;
if (typeof actionValue === "object" && actionValue !== null) {
if ("text" in actionValue && typeof actionValue.text === "string") {
return actionValue.text;
const FEISHU_APPROVAL_CARD_TTL_MS = 5 * 60_000;
const FEISHU_CARD_ACTION_TOKEN_TTL_MS = 15 * 60_000;
const processedCardActionTokens = new Map<
string,
{ status: "inflight" | "completed"; expiresAt: number }
>();
export function resetProcessedFeishuCardActionTokensForTests(): void {
processedCardActionTokens.clear();
}
function pruneProcessedCardActionTokens(now: number): void {
for (const [key, entry] of processedCardActionTokens.entries()) {
if (entry.expiresAt <= now) {
processedCardActionTokens.delete(key);
}
if ("command" in actionValue && typeof actionValue.command === "string") {
return actionValue.command;
}
return JSON.stringify(actionValue);
}
return String(actionValue);
}
function beginFeishuCardActionToken(params: {
token: string;
accountId: string;
now?: number;
}): boolean {
const now = params.now ?? Date.now();
pruneProcessedCardActionTokens(now);
const normalizedToken = params.token.trim();
if (!normalizedToken) {
return true;
}
const key = `${params.accountId}:${normalizedToken}`;
const existing = processedCardActionTokens.get(key);
if (existing && existing.expiresAt > now) {
return false;
}
processedCardActionTokens.set(key, {
status: "inflight",
expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS,
});
return true;
}
function completeFeishuCardActionToken(params: {
token: string;
accountId: string;
now?: number;
}): void {
const now = params.now ?? Date.now();
const normalizedToken = params.token.trim();
if (!normalizedToken) {
return;
}
processedCardActionTokens.set(`${params.accountId}:${normalizedToken}`, {
status: "completed",
expiresAt: now + FEISHU_CARD_ACTION_TOKEN_TTL_MS,
});
}
function releaseFeishuCardActionToken(params: { token: string; accountId: string }): void {
const normalizedToken = params.token.trim();
if (!normalizedToken) {
return;
}
processedCardActionTokens.delete(`${params.accountId}:${normalizedToken}`);
}
function buildSyntheticMessageEvent(
event: FeishuCardActionEvent,
content: string,
chatType?: "p2p" | "group",
): FeishuMessageEvent {
return {
sender: {
sender_id: {
open_id: event.operator.open_id,
user_id: event.operator.user_id,
union_id: event.operator.union_id,
},
},
message: {
message_id: `card-action-${event.token}`,
chat_id: event.context.chat_id || event.operator.open_id,
chat_type: chatType ?? (event.context.chat_id ? "group" : "p2p"),
message_type: "text",
content: JSON.stringify({ text: content }),
},
};
}
function resolveCallbackTarget(event: FeishuCardActionEvent): string {
const chatId = event.context.chat_id?.trim();
if (chatId) {
return `chat:${chatId}`;
}
return `user:${event.operator.open_id}`;
}
async function dispatchSyntheticCommand(params: {
cfg: ClawdbotConfig;
event: FeishuCardActionEvent;
command: string;
botOpenId?: string;
runtime?: RuntimeEnv;
accountId?: string;
chatType?: "p2p" | "group";
}): Promise<void> {
await handleFeishuMessage({
cfg: params.cfg,
event: buildSyntheticMessageEvent(params.event, params.command, params.chatType),
botOpenId: params.botOpenId,
runtime: params.runtime,
accountId: params.accountId,
});
}
async function sendInvalidInteractionNotice(params: {
cfg: ClawdbotConfig;
event: FeishuCardActionEvent;
reason: "malformed" | "stale" | "wrong_user" | "wrong_conversation";
accountId?: string;
}): Promise<void> {
const reasonText =
params.reason === "stale"
? "This card action has expired. Open a fresh launcher card and try again."
: params.reason === "wrong_user"
? "This card action belongs to a different user."
: params.reason === "wrong_conversation"
? "This card action belongs to a different conversation."
: "This card action payload is invalid.";
await sendMessageFeishu({
cfg: params.cfg,
to: resolveCallbackTarget(params.event),
text: `⚠️ ${reasonText}`,
accountId: params.accountId,
});
}
export async function handleFeishuCardAction(params: {
@ -44,36 +176,135 @@ export async function handleFeishuCardAction(params: {
const { cfg, event, runtime, accountId } = params;
const account = resolveFeishuAccount({ cfg, accountId });
const log = runtime?.log ?? console.log;
const content = buildCardActionTextFallback(event);
// Construct a synthetic message event
const messageEvent: FeishuMessageEvent = {
sender: {
sender_id: {
open_id: event.operator.open_id,
user_id: event.operator.user_id,
union_id: event.operator.union_id,
},
},
message: {
message_id: `card-action-${event.token}`,
chat_id: event.context.chat_id || event.operator.open_id,
chat_type: event.context.chat_id ? "group" : "p2p",
message_type: "text",
content: JSON.stringify({ text: content }),
},
};
log(
`feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`,
);
// Dispatch as normal message
await handleFeishuMessage({
cfg,
event: messageEvent,
botOpenId: params.botOpenId,
runtime,
accountId,
const decoded = decodeFeishuCardAction({ event });
const claimedToken = beginFeishuCardActionToken({
token: event.token,
accountId: account.accountId,
});
if (!claimedToken) {
log(`feishu[${account.accountId}]: skipping duplicate card action token ${event.token}`);
return;
}
try {
if (decoded.kind === "invalid") {
log(
`feishu[${account.accountId}]: rejected card action from ${event.operator.open_id}: ${decoded.reason}`,
);
await sendInvalidInteractionNotice({
cfg,
event,
reason: decoded.reason,
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
if (decoded.kind === "structured") {
const { envelope } = decoded;
log(
`feishu[${account.accountId}]: handling structured card action ${envelope.a} from ${event.operator.open_id}`,
);
if (envelope.a === FEISHU_APPROVAL_REQUEST_ACTION) {
const command = typeof envelope.m?.command === "string" ? envelope.m.command.trim() : "";
if (!command) {
await sendInvalidInteractionNotice({
cfg,
event,
reason: "malformed",
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
const prompt =
typeof envelope.m?.prompt === "string" && envelope.m.prompt.trim()
? envelope.m.prompt
: `Run \`${command}\` in this Feishu conversation?`;
await sendCardFeishu({
cfg,
to: resolveCallbackTarget(event),
card: createApprovalCard({
operatorOpenId: event.operator.open_id,
chatId: event.context.chat_id || undefined,
command,
prompt,
sessionKey: envelope.c?.s,
expiresAt: Date.now() + FEISHU_APPROVAL_CARD_TTL_MS,
chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"),
confirmLabel: command === "/reset" ? "Reset" : "Confirm",
}),
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
if (envelope.a === FEISHU_APPROVAL_CANCEL_ACTION) {
await sendMessageFeishu({
cfg,
to: resolveCallbackTarget(event),
text: "Cancelled.",
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
if (envelope.a === FEISHU_APPROVAL_CONFIRM_ACTION || envelope.k === "quick") {
const command = envelope.q?.trim();
if (!command) {
await sendInvalidInteractionNotice({
cfg,
event,
reason: "malformed",
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
await dispatchSyntheticCommand({
cfg,
event,
command,
botOpenId: params.botOpenId,
runtime,
accountId,
chatType: envelope.c?.t ?? (event.context.chat_id ? "group" : "p2p"),
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
await sendInvalidInteractionNotice({
cfg,
event,
reason: "malformed",
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
return;
}
const content = buildFeishuCardActionTextFallback(event);
log(
`feishu[${account.accountId}]: handling card action from ${event.operator.open_id}: ${content}`,
);
await dispatchSyntheticCommand({
cfg,
event,
command: content,
botOpenId: params.botOpenId,
runtime,
accountId,
});
completeFeishuCardActionToken({ token: event.token, accountId: account.accountId });
} catch (err) {
releaseFeishuCardActionToken({ token: event.token, accountId: account.accountId });
throw err;
}
}

View File

@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import {
buildFeishuCardActionTextFallback,
createFeishuCardInteractionEnvelope,
decodeFeishuCardAction,
} from "./card-interaction.js";
describe("feishu card interaction decoder", () => {
it("decodes valid structured payloads", () => {
const result = decodeFeishuCardAction({
now: 1_700_000_000_000,
event: {
operator: { open_id: "u123" },
context: { chat_id: "chat1" },
action: {
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: { u: "u123", h: "chat1", t: "group", e: 1_700_000_060_000 },
}),
},
},
});
expect(result).toEqual(
expect.objectContaining({
kind: "structured",
envelope: expect.objectContaining({
q: "/help",
}),
}),
);
});
it("falls back for legacy text-like payloads", () => {
const result = decodeFeishuCardAction({
event: {
operator: { open_id: "u123" },
context: { chat_id: "chat1" },
action: { value: { text: "/ping" } },
},
});
expect(result).toEqual({ kind: "legacy", text: "/ping" });
expect(
buildFeishuCardActionTextFallback({
operator: { open_id: "u123" },
context: { chat_id: "chat1" },
action: { value: { command: "/new" } },
}),
).toBe("/new");
});
it("rejects malformed structured payloads", () => {
const result = decodeFeishuCardAction({
event: {
operator: { open_id: "u123" },
context: { chat_id: "chat1" },
action: {
value: {
oc: "ocf1",
k: "quick",
a: "broken",
m: { bad: { nested: true } },
},
},
},
});
expect(result).toEqual({ kind: "invalid", reason: "malformed" });
});
it("rejects stale payloads", () => {
const result = decodeFeishuCardAction({
now: 100,
event: {
operator: { open_id: "u123" },
context: { chat_id: "chat1" },
action: {
value: createFeishuCardInteractionEnvelope({
k: "button",
a: "stale",
c: { e: 99, t: "group" },
}),
},
},
});
expect(result).toEqual({ kind: "invalid", reason: "stale" });
});
it("rejects wrong-conversation payloads when chat context is enforced", () => {
const result = decodeFeishuCardAction({
event: {
operator: { open_id: "u123" },
context: { chat_id: "chat2" },
action: {
value: createFeishuCardInteractionEnvelope({
k: "button",
a: "scoped",
c: { u: "u123", h: "chat1", t: "group", e: Date.now() + 60_000 },
}),
},
},
});
expect(result).toEqual({ kind: "invalid", reason: "wrong_conversation" });
});
it("rejects malformed chat-type context", () => {
const result = decodeFeishuCardAction({
event: {
operator: { open_id: "u123" },
context: { chat_id: "chat1" },
action: {
value: {
oc: "ocf1",
k: "button",
a: "bad",
c: { t: "private" },
},
},
},
});
expect(result).toEqual({ kind: "invalid", reason: "malformed" });
});
});

View File

@ -0,0 +1,168 @@
export const FEISHU_CARD_INTERACTION_VERSION = "ocf1";
export type FeishuCardInteractionKind = "button" | "quick" | "meta";
export type FeishuCardInteractionReason =
| "malformed"
| "stale"
| "wrong_user"
| "wrong_conversation";
export type FeishuCardInteractionMetadata = Record<
string,
string | number | boolean | null | undefined
>;
export type FeishuCardInteractionEnvelope = {
oc: typeof FEISHU_CARD_INTERACTION_VERSION;
k: FeishuCardInteractionKind;
a: string;
q?: string;
m?: FeishuCardInteractionMetadata;
c?: {
u?: string;
h?: string;
s?: string;
e?: number;
t?: "p2p" | "group";
};
};
export type FeishuCardActionEventLike = {
operator: {
open_id?: string;
};
action: {
value: unknown;
};
context: {
chat_id?: string;
};
};
export type DecodedFeishuCardAction =
| {
kind: "structured";
envelope: FeishuCardInteractionEnvelope;
}
| {
kind: "legacy";
text: string;
}
| {
kind: "invalid";
reason: FeishuCardInteractionReason;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isInteractionKind(value: unknown): value is FeishuCardInteractionKind {
return value === "button" || value === "quick" || value === "meta";
}
function isMetadataValue(value: unknown): value is string | number | boolean | null | undefined {
return (
value === null ||
value === undefined ||
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
);
}
export function createFeishuCardInteractionEnvelope(
envelope: Omit<FeishuCardInteractionEnvelope, "oc">,
): FeishuCardInteractionEnvelope {
return {
oc: FEISHU_CARD_INTERACTION_VERSION,
...envelope,
};
}
export function buildFeishuCardActionTextFallback(event: FeishuCardActionEventLike): string {
const actionValue = event.action.value;
if (isRecord(actionValue)) {
if (typeof actionValue.text === "string") {
return actionValue.text;
}
if (typeof actionValue.command === "string") {
return actionValue.command;
}
return JSON.stringify(actionValue);
}
return String(actionValue);
}
export function decodeFeishuCardAction(params: {
event: FeishuCardActionEventLike;
now?: number;
}): DecodedFeishuCardAction {
const { event, now = Date.now() } = params;
const actionValue = event.action.value;
if (!isRecord(actionValue) || actionValue.oc !== FEISHU_CARD_INTERACTION_VERSION) {
return {
kind: "legacy",
text: buildFeishuCardActionTextFallback(event),
};
}
if (!isInteractionKind(actionValue.k) || typeof actionValue.a !== "string" || !actionValue.a) {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.q !== undefined && typeof actionValue.q !== "string") {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.m !== undefined) {
if (!isRecord(actionValue.m)) {
return { kind: "invalid", reason: "malformed" };
}
for (const value of Object.values(actionValue.m)) {
if (!isMetadataValue(value)) {
return { kind: "invalid", reason: "malformed" };
}
}
}
if (actionValue.c !== undefined) {
if (!isRecord(actionValue.c)) {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.c.u !== undefined && typeof actionValue.c.u !== "string") {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.c.h !== undefined && typeof actionValue.c.h !== "string") {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.c.s !== undefined && typeof actionValue.c.s !== "string") {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.c.e !== undefined && !Number.isFinite(actionValue.c.e)) {
return { kind: "invalid", reason: "malformed" };
}
if (actionValue.c.t !== undefined && actionValue.c.t !== "p2p" && actionValue.c.t !== "group") {
return { kind: "invalid", reason: "malformed" };
}
if (typeof actionValue.c.e === "number" && actionValue.c.e < now) {
return { kind: "invalid", reason: "stale" };
}
const expectedUser = actionValue.c.u?.trim();
if (expectedUser && expectedUser !== (event.operator.open_id ?? "").trim()) {
return { kind: "invalid", reason: "wrong_user" };
}
const expectedChat = actionValue.c.h?.trim();
if (expectedChat && expectedChat !== (event.context.chat_id ?? "").trim()) {
return { kind: "invalid", reason: "wrong_conversation" };
}
}
return {
kind: "structured",
envelope: actionValue as FeishuCardInteractionEnvelope,
};
}

View File

@ -0,0 +1,65 @@
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
import { buildFeishuCardButton, buildFeishuCardInteractionContext } from "./card-ux-shared.js";
export const FEISHU_APPROVAL_REQUEST_ACTION = "feishu.quick_actions.request_approval";
export const FEISHU_APPROVAL_CONFIRM_ACTION = "feishu.approval.confirm";
export const FEISHU_APPROVAL_CANCEL_ACTION = "feishu.approval.cancel";
export function createApprovalCard(params: {
operatorOpenId: string;
chatId?: string;
command: string;
prompt: string;
expiresAt: number;
chatType?: "p2p" | "group";
sessionKey?: string;
confirmLabel?: string;
cancelLabel?: string;
}): Record<string, unknown> {
const context = buildFeishuCardInteractionContext(params);
return {
schema: "2.0",
config: {
wide_screen_mode: true,
},
header: {
title: {
tag: "plain_text",
content: "Confirm action",
},
template: "orange",
},
body: {
elements: [
{
tag: "markdown",
content: params.prompt,
},
{
tag: "action",
actions: [
buildFeishuCardButton({
label: params.confirmLabel ?? "Confirm",
type: "primary",
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: FEISHU_APPROVAL_CONFIRM_ACTION,
q: params.command,
c: context,
}),
}),
buildFeishuCardButton({
label: params.cancelLabel ?? "Cancel",
value: createFeishuCardInteractionEnvelope({
k: "button",
a: FEISHU_APPROVAL_CANCEL_ACTION,
c: context,
}),
}),
],
},
],
},
};
}

View File

@ -0,0 +1,98 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import {
createQuickActionLauncherCard,
isFeishuQuickActionMenuEventKey,
maybeHandleFeishuQuickActionMenu,
} from "./card-ux-launcher.js";
const sendCardFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./send.js", () => ({
sendCardFeishu: sendCardFeishuMock,
}));
describe("feishu quick-action launcher", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("recognizes the quick-actions bot menu key", () => {
expect(isFeishuQuickActionMenuEventKey("quick-actions")).toBe(true);
expect(isFeishuQuickActionMenuEventKey("other")).toBe(false);
});
it("builds a launcher card with interactive actions", () => {
const card = createQuickActionLauncherCard({
operatorOpenId: "u123",
chatId: "chat1",
expiresAt: 123,
sessionKey: "agent:codex:feishu:chat:chat1",
}) as {
body: {
elements: Array<{
tag: string;
actions?: Array<{ value?: { oc?: string; c?: { s?: string; t?: string } } }>;
}>;
};
};
const actionBlock = card.body.elements.find((entry) => entry.tag === "action");
expect(actionBlock?.actions).toHaveLength(3);
expect(actionBlock?.actions?.[0]?.value?.oc).toBe("ocf1");
expect(actionBlock?.actions?.[0]?.value?.c?.s).toBe("agent:codex:feishu:chat:chat1");
expect(actionBlock?.actions?.[0]?.value?.c?.t).toBeUndefined();
});
it("opens the launcher from a supported bot menu event", async () => {
sendCardFeishuMock.mockResolvedValue({ messageId: "m1", chatId: "c1" });
const handled = await maybeHandleFeishuQuickActionMenu({
cfg: {} as any,
eventKey: "quick-actions",
operatorOpenId: "u123",
accountId: "main",
now: 100,
});
expect(handled).toBe(true);
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "user:u123",
accountId: "main",
card: expect.objectContaining({
body: expect.objectContaining({
elements: expect.arrayContaining([
expect.objectContaining({
tag: "action",
actions: expect.arrayContaining([
expect.objectContaining({
value: expect.objectContaining({
c: expect.objectContaining({
t: "p2p",
}),
}),
}),
]),
}),
]),
}),
}),
}),
);
});
it("falls back to legacy menu handling when launcher send fails", async () => {
sendCardFeishuMock.mockRejectedValueOnce(new Error("network"));
const handled = await maybeHandleFeishuQuickActionMenu({
cfg: {} as any,
eventKey: "quick-actions",
operatorOpenId: "u123",
accountId: "main",
runtime: { log: vi.fn() } as any,
now: 100,
});
expect(handled).toBe(false);
});
});

View File

@ -0,0 +1,120 @@
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
import { createFeishuCardInteractionEnvelope } from "./card-interaction.js";
import { FEISHU_APPROVAL_REQUEST_ACTION } from "./card-ux-approval.js";
import { buildFeishuCardButton, buildFeishuCardInteractionContext } from "./card-ux-shared.js";
import { sendCardFeishu } from "./send.js";
export const FEISHU_QUICK_ACTION_CARD_TTL_MS = 10 * 60_000;
const QUICK_ACTION_MENU_KEYS = new Set(["quick-actions", "quick_actions", "launcher"]);
export function isFeishuQuickActionMenuEventKey(eventKey: string): boolean {
return QUICK_ACTION_MENU_KEYS.has(eventKey.trim().toLowerCase());
}
export function createQuickActionLauncherCard(params: {
operatorOpenId: string;
chatId?: string;
expiresAt: number;
chatType?: "p2p" | "group";
sessionKey?: string;
}): Record<string, unknown> {
const context = buildFeishuCardInteractionContext(params);
return {
schema: "2.0",
config: {
wide_screen_mode: true,
},
header: {
title: {
tag: "plain_text",
content: "Quick actions",
},
template: "indigo",
},
body: {
elements: [
{
tag: "markdown",
content: "Run common actions without typing raw commands.",
},
{
tag: "action",
actions: [
buildFeishuCardButton({
label: "Help",
value: createFeishuCardInteractionEnvelope({
k: "quick",
a: "feishu.quick_actions.help",
q: "/help",
c: context,
}),
}),
buildFeishuCardButton({
label: "New session",
type: "primary",
value: createFeishuCardInteractionEnvelope({
k: "meta",
a: FEISHU_APPROVAL_REQUEST_ACTION,
m: {
command: "/new",
prompt: "Start a fresh session? This will reset the current chat context.",
},
c: context,
}),
}),
buildFeishuCardButton({
label: "Reset",
type: "danger",
value: createFeishuCardInteractionEnvelope({
k: "meta",
a: FEISHU_APPROVAL_REQUEST_ACTION,
m: {
command: "/reset",
prompt: "Reset this session now? Any active conversation state will be cleared.",
},
c: context,
}),
}),
],
},
],
},
};
}
export async function maybeHandleFeishuQuickActionMenu(params: {
cfg: ClawdbotConfig;
eventKey: string;
operatorOpenId: string;
runtime?: RuntimeEnv;
accountId?: string;
now?: number;
}): Promise<boolean> {
if (!isFeishuQuickActionMenuEventKey(params.eventKey)) {
return false;
}
const expiresAt = (params.now ?? Date.now()) + FEISHU_QUICK_ACTION_CARD_TTL_MS;
try {
await sendCardFeishu({
cfg: params.cfg,
to: `user:${params.operatorOpenId}`,
card: createQuickActionLauncherCard({
operatorOpenId: params.operatorOpenId,
expiresAt,
chatType: "p2p",
}),
accountId: params.accountId,
});
} catch (err) {
params.runtime?.log?.(
`feishu[${params.accountId ?? "default"}]: failed to open quick-action launcher for ${params.operatorOpenId}: ${String(err)}`,
);
return false;
}
params.runtime?.log?.(
`feishu[${params.accountId ?? "default"}]: opened quick-action launcher for ${params.operatorOpenId}`,
);
return true;
}

View File

@ -0,0 +1,33 @@
import type { FeishuCardInteractionEnvelope } from "./card-interaction.js";
export function buildFeishuCardButton(params: {
label: string;
value: FeishuCardInteractionEnvelope;
type?: "default" | "primary" | "danger";
}) {
return {
tag: "button",
text: {
tag: "plain_text",
content: params.label,
},
type: params.type ?? "default",
value: params.value,
};
}
export function buildFeishuCardInteractionContext(params: {
operatorOpenId: string;
chatId?: string;
expiresAt: number;
chatType?: "p2p" | "group";
sessionKey?: string;
}) {
return {
u: params.operatorOpenId,
...(params.chatId ? { h: params.chatId } : {}),
...(params.sessionKey ? { s: params.sessionKey } : {}),
e: params.expiresAt,
...(params.chatType ? { t: params.chatType } : {}),
};
}

View File

@ -10,6 +10,7 @@ import {
type FeishuBotAddedEvent,
} from "./bot.js";
import { handleFeishuCardAction, type FeishuCardActionEvent } from "./card-action.js";
import { maybeHandleFeishuQuickActionMenu } from "./card-ux-launcher.js";
import { createEventDispatcher } from "./client.js";
import {
hasProcessedFeishuMessage,
@ -513,7 +514,7 @@ function registerEventHandlers(
try {
const event = data as {
event_key?: string;
timestamp?: number;
timestamp?: string | number;
operator?: {
operator_name?: string;
operator_id?: { open_id?: string; user_id?: string; union_id?: string };
@ -543,14 +544,28 @@ function registerEventHandlers(
}),
},
};
const promise = handleFeishuMessage({
const handleLegacyMenu = () =>
handleFeishuMessage({
cfg,
event: syntheticEvent,
botOpenId: botOpenIds.get(accountId),
botName: botNames.get(accountId),
runtime,
chatHistories,
accountId,
});
const promise = maybeHandleFeishuQuickActionMenu({
cfg,
event: syntheticEvent,
botOpenId: botOpenIds.get(accountId),
botName: botNames.get(accountId),
eventKey,
operatorOpenId,
runtime,
chatHistories,
accountId,
}).then((handledMenu) => {
if (handledMenu) {
return;
}
return handleLegacyMenu();
});
if (fireAndForget) {
promise.catch((err) => {

View File

@ -0,0 +1,229 @@
import type { ClawdbotConfig, RuntimeEnv } from "openclaw/plugin-sdk/feishu";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { hasControlCommand } from "../../../src/auto-reply/command-detection.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../../../src/auto-reply/inbound-debounce.js";
import { createPluginRuntimeMock } from "../../test-utils/plugin-runtime-mock.js";
import { monitorSingleAccount } from "./monitor.account.js";
import { setFeishuRuntime } from "./runtime.js";
import type { ResolvedFeishuAccount } from "./types.js";
const createEventDispatcherMock = vi.hoisted(() => vi.fn());
const monitorWebSocketMock = vi.hoisted(() => vi.fn(async () => {}));
const monitorWebhookMock = vi.hoisted(() => vi.fn(async () => {}));
const handleFeishuMessageMock = vi.hoisted(() => vi.fn(async () => {}));
const sendCardFeishuMock = vi.hoisted(() => vi.fn(async () => ({ messageId: "m1", chatId: "c1" })));
const createFeishuThreadBindingManagerMock = vi.hoisted(() => vi.fn(() => ({ stop: vi.fn() })));
let handlers: Record<string, (data: unknown) => Promise<void>> = {};
vi.mock("./client.js", () => ({
createEventDispatcher: createEventDispatcherMock,
}));
vi.mock("./monitor.transport.js", () => ({
monitorWebSocket: monitorWebSocketMock,
monitorWebhook: monitorWebhookMock,
}));
vi.mock("./bot.js", async () => {
const actual = await vi.importActual<typeof import("./bot.js")>("./bot.js");
return {
...actual,
handleFeishuMessage: handleFeishuMessageMock,
};
});
vi.mock("./send.js", async () => {
const actual = await vi.importActual<typeof import("./send.js")>("./send.js");
return {
...actual,
sendCardFeishu: sendCardFeishuMock,
};
});
vi.mock("./thread-bindings.js", () => ({
createFeishuThreadBindingManager: createFeishuThreadBindingManagerMock,
}));
function buildAccount(): ResolvedFeishuAccount {
return {
accountId: "default",
enabled: true,
configured: true,
appId: "cli_test",
appSecret: "secret_test", // pragma: allowlist secret
domain: "feishu",
config: {
enabled: true,
connectionMode: "websocket",
},
} as ResolvedFeishuAccount;
}
async function registerHandlers() {
setFeishuRuntime(
createPluginRuntimeMock({
channel: {
debounce: {
createInboundDebouncer,
resolveInboundDebounceMs,
},
text: {
hasControlCommand,
},
},
}),
);
const register = vi.fn((registered: Record<string, (data: unknown) => Promise<void>>) => {
handlers = registered;
});
createEventDispatcherMock.mockReturnValue({ register });
await monitorSingleAccount({
cfg: {} as ClawdbotConfig,
account: buildAccount(),
runtime: {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
} as RuntimeEnv,
botOpenIdSource: {
kind: "prefetched",
botOpenId: "ou_bot",
botName: "Bot",
},
});
const onBotMenu = handlers["application.bot.menu_v6"];
if (!onBotMenu) {
throw new Error("missing application.bot.menu_v6 handler");
}
return onBotMenu;
}
describe("Feishu bot menu handler", () => {
beforeEach(() => {
handlers = {};
vi.clearAllMocks();
});
it("opens the quick-action launcher card at the webhook/event layer", async () => {
const onBotMenu = await registerHandlers();
await onBotMenu({
event_key: "quick-actions",
timestamp: "1700000000000",
operator: {
operator_id: {
open_id: "ou_user1",
user_id: "user_1",
union_id: "union_1",
},
},
});
expect(sendCardFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "user:ou_user1",
card: expect.objectContaining({
header: expect.objectContaining({
title: expect.objectContaining({ content: "Quick actions" }),
}),
}),
}),
);
expect(handleFeishuMessageMock).not.toHaveBeenCalled();
});
it("does not block bot-menu handling on quick-action launcher send", async () => {
const onBotMenu = await registerHandlers();
let resolveSend: (() => void) | undefined;
sendCardFeishuMock.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSend = () => resolve({ messageId: "m1", chatId: "c1" });
}),
);
const pending = onBotMenu({
event_key: "quick-actions",
timestamp: "1700000000000",
operator: {
operator_id: {
open_id: "ou_user1",
user_id: "user_1",
union_id: "union_1",
},
},
});
let settled = false;
pending.finally(() => {
settled = true;
});
await Promise.resolve();
expect(settled).toBe(true);
resolveSend?.();
await pending;
});
it("falls back to the legacy /menu synthetic message path for unrelated bot menu keys", async () => {
const onBotMenu = await registerHandlers();
await onBotMenu({
event_key: "custom-key",
timestamp: "1700000000000",
operator: {
operator_id: {
open_id: "ou_user1",
user_id: "user_1",
union_id: "union_1",
},
},
});
expect(handleFeishuMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
content: '{"text":"/menu custom-key"}',
}),
}),
}),
);
expect(sendCardFeishuMock).not.toHaveBeenCalled();
});
it("falls back to the legacy /menu path when launcher rendering fails", async () => {
const onBotMenu = await registerHandlers();
sendCardFeishuMock.mockRejectedValueOnce(new Error("boom"));
await onBotMenu({
event_key: "quick-actions",
timestamp: "1700000000000",
operator: {
operator_id: {
open_id: "ou_user1",
user_id: "user_1",
union_id: "union_1",
},
},
});
await vi.waitFor(() => {
expect(handleFeishuMessageMock).toHaveBeenCalledWith(
expect.objectContaining({
event: expect.objectContaining({
message: expect.objectContaining({
content: '{"text":"/menu quick-actions"}',
}),
}),
}),
);
});
});
});

View File

@ -3,7 +3,12 @@ import {
getScopedCredentialValue,
setScopedCredentialValue,
} from "../../src/agents/tools/web-search-plugin-factory.js";
import {
GOOGLE_GEMINI_DEFAULT_MODEL,
applyGoogleGeminiModelDefault,
} from "../../src/commands/google-gemini-model-default.js";
import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import type { OpenClawPluginApi } from "../../src/plugins/types.js";
import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js";
import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js";
@ -19,7 +24,28 @@ const googlePlugin = {
label: "Google AI Studio",
docsPath: "/providers/models",
envVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"],
auth: [],
auth: [
createProviderApiKeyAuthMethod({
providerId: "google",
methodId: "api-key",
label: "Google Gemini API key",
hint: "AI Studio / Gemini API key",
optionKey: "geminiApiKey",
flagName: "--gemini-api-key",
envVar: "GEMINI_API_KEY",
promptMessage: "Enter Gemini API key",
defaultModel: GOOGLE_GEMINI_DEFAULT_MODEL,
expectedProviders: ["google"],
applyConfig: (cfg) => applyGoogleGeminiModelDefault(cfg).next,
wizard: {
choiceId: "gemini-api-key",
choiceLabel: "Google Gemini API key",
groupId: "google",
groupLabel: "Google",
groupHint: "Gemini API key + OAuth",
},
}),
],
resolveDynamicModel: (ctx) =>
resolveGoogle31ForwardCompatModel({ providerId: "google", ctx }),
isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId),

View File

@ -12,7 +12,12 @@ import {
buildMinimaxPortalProvider,
buildMinimaxProvider,
} from "../../src/agents/models-config.providers.static.js";
import {
applyMinimaxApiConfig,
applyMinimaxApiConfigCn,
} from "../../src/commands/onboard-auth.config-minimax.js";
import { fetchMinimaxUsage } from "../../src/infra/provider-usage.fetch.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import { loginMiniMaxPortalOAuth, type MiniMaxRegion } from "./oauth.js";
const API_PROVIDER_ID = "minimax";
@ -160,7 +165,54 @@ const minimaxPlugin = {
label: PROVIDER_LABEL,
docsPath: "/providers/minimax",
envVars: ["MINIMAX_API_KEY"],
auth: [],
auth: [
createProviderApiKeyAuthMethod({
providerId: API_PROVIDER_ID,
methodId: "api-global",
label: "MiniMax API key (Global)",
hint: "Global endpoint - api.minimax.io",
optionKey: "minimaxApiKey",
flagName: "--minimax-api-key",
envVar: "MINIMAX_API_KEY",
promptMessage:
"Enter MiniMax API key (sk-api- or sk-cp-)\nhttps://platform.minimax.io/user-center/basic-information/interface-key",
profileId: "minimax:global",
defaultModel: modelRef(DEFAULT_MODEL),
expectedProviders: ["minimax"],
applyConfig: (cfg) => applyMinimaxApiConfig(cfg),
wizard: {
choiceId: "minimax-global-api",
choiceLabel: "MiniMax API key (Global)",
choiceHint: "Global endpoint - api.minimax.io",
groupId: "minimax",
groupLabel: "MiniMax",
groupHint: "M2.5 (recommended)",
},
}),
createProviderApiKeyAuthMethod({
providerId: API_PROVIDER_ID,
methodId: "api-cn",
label: "MiniMax API key (CN)",
hint: "CN endpoint - api.minimaxi.com",
optionKey: "minimaxApiKey",
flagName: "--minimax-api-key",
envVar: "MINIMAX_API_KEY",
promptMessage:
"Enter MiniMax CN API key (sk-api- or sk-cp-)\nhttps://platform.minimaxi.com/user-center/basic-information/interface-key",
profileId: "minimax:cn",
defaultModel: modelRef(DEFAULT_MODEL),
expectedProviders: ["minimax", "minimax-cn"],
applyConfig: (cfg) => applyMinimaxApiConfigCn(cfg),
wizard: {
choiceId: "minimax-cn-api",
choiceLabel: "MiniMax API key (CN)",
choiceHint: "CN endpoint - api.minimaxi.com",
groupId: "minimax",
groupLabel: "MiniMax",
groupHint: "M2.5 (recommended)",
},
}),
],
catalog: {
order: "simple",
run: async (ctx) => resolveApiCatalog(ctx),
@ -190,6 +242,14 @@ const minimaxPlugin = {
label: "MiniMax OAuth (Global)",
hint: "Global endpoint - api.minimax.io",
kind: "device_code",
wizard: {
choiceId: "minimax-global-oauth",
choiceLabel: "MiniMax OAuth (Global)",
choiceHint: "Global endpoint - api.minimax.io",
groupId: "minimax",
groupLabel: "MiniMax",
groupHint: "M2.5 (recommended)",
},
run: createOAuthHandler("global"),
},
{
@ -197,6 +257,14 @@ const minimaxPlugin = {
label: "MiniMax OAuth (CN)",
hint: "CN endpoint - api.minimaxi.com",
kind: "device_code",
wizard: {
choiceId: "minimax-cn-oauth",
choiceLabel: "MiniMax OAuth (CN)",
choiceHint: "CN endpoint - api.minimaxi.com",
groupId: "minimax",
groupLabel: "MiniMax",
groupHint: "M2.5 (recommended)",
},
run: createOAuthHandler("cn"),
},
],

View File

@ -17,6 +17,7 @@ import {
PAIRING_APPROVED_MESSAGE,
} from "openclaw/plugin-sdk/msteams";
import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
import type { ProbeMSTeamsResult } from "./probe.js";
import {
normalizeMSTeamsMessagingTarget,
normalizeMSTeamsUserInput,
@ -47,6 +48,16 @@ const meta = {
order: 60,
} as const;
const TEAMS_GRAPH_PERMISSION_HINTS: Record<string, string> = {
"ChannelMessage.Read.All": "channel history",
"Chat.Read.All": "chat history",
"Channel.ReadBasic.All": "channel list",
"Team.ReadBasic.All": "team list",
"TeamsActivity.Read.All": "teams activity",
"Sites.Read.All": "files (SharePoint)",
"Files.Read.All": "files (OneDrive)",
};
async function loadMSTeamsChannelRuntime() {
return await import("./channel.runtime.js");
}
@ -435,6 +446,40 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
}),
probeAccount: async ({ cfg }) =>
await (await loadMSTeamsChannelRuntime()).probeMSTeams(cfg.channels?.msteams),
formatCapabilitiesProbe: ({ probe }) => {
const teamsProbe = probe as ProbeMSTeamsResult | undefined;
const lines: Array<{ text: string; tone?: "error" }> = [];
const appId = typeof teamsProbe?.appId === "string" ? teamsProbe.appId.trim() : "";
if (appId) {
lines.push({ text: `App: ${appId}` });
}
const graph = teamsProbe?.graph;
if (graph) {
const roles = Array.isArray(graph.roles)
? graph.roles.map((role) => String(role).trim()).filter(Boolean)
: [];
const scopes = Array.isArray(graph.scopes)
? graph.scopes.map((scope) => String(scope).trim()).filter(Boolean)
: [];
const formatPermission = (permission: string) => {
const hint = TEAMS_GRAPH_PERMISSION_HINTS[permission];
return hint ? `${permission} (${hint})` : permission;
};
if (graph.ok === false) {
lines.push({ text: `Graph: ${graph.error ?? "failed"}`, tone: "error" });
} else if (roles.length > 0 || scopes.length > 0) {
if (roles.length > 0) {
lines.push({ text: `Graph roles: ${roles.map(formatPermission).join(", ")}` });
}
if (scopes.length > 0) {
lines.push({ text: `Graph scopes: ${scopes.map(formatPermission).join(", ")}` });
}
} else if (graph.ok === true) {
lines.push({ text: "Graph: ok" });
}
}
return lines;
},
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
enabled: account.enabled,

View File

@ -4,6 +4,7 @@ import type {
ProviderResolveDynamicModelContext,
ProviderRuntimeModel,
} from "openclaw/plugin-sdk/core";
import { CODEX_CLI_PROFILE_ID } from "../../src/agents/auth-profiles.js";
import { listProfilesForProvider } from "../../src/agents/auth-profiles/profiles.js";
import { ensureAuthProfileStore } from "../../src/agents/auth-profiles/store.js";
import type { OAuthCredential } from "../../src/agents/auth-profiles/types.js";
@ -194,6 +195,7 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin {
id: PROVIDER_ID,
label: "OpenAI Codex",
docsPath: "/providers/models",
deprecatedProfileIds: [CODEX_CLI_PROFILE_ID],
auth: [
{
id: "oauth",

View File

@ -4,6 +4,11 @@ import {
} from "openclaw/plugin-sdk/core";
import { normalizeModelCompat } from "../../src/agents/model-compat.js";
import { normalizeProviderId } from "../../src/agents/model-selection.js";
import {
applyOpenAIConfig,
OPENAI_DEFAULT_MODEL,
} from "../../src/commands/openai-model-default.js";
import { createProviderApiKeyAuthMethod } from "../../src/plugins/provider-api-key-auth.js";
import type { ProviderPlugin } from "../../src/plugins/types.js";
import {
cloneFirstTemplateModel,
@ -89,7 +94,28 @@ export function buildOpenAIProvider(): ProviderPlugin {
label: "OpenAI",
docsPath: "/providers/models",
envVars: ["OPENAI_API_KEY"],
auth: [],
auth: [
createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "OpenAI API key",
hint: "Direct OpenAI API key",
optionKey: "openaiApiKey",
flagName: "--openai-api-key",
envVar: "OPENAI_API_KEY",
promptMessage: "Enter OpenAI API key",
defaultModel: OPENAI_DEFAULT_MODEL,
expectedProviders: ["openai"],
applyConfig: (cfg) => applyOpenAIConfig(cfg),
wizard: {
choiceId: "openai-api-key",
choiceLabel: "OpenAI API key",
groupId: "openai",
groupLabel: "OpenAI",
groupHint: "Codex OAuth + API key",
},
}),
],
resolveDynamicModel: (ctx) => resolveOpenAIGpt54ForwardCompatModel(ctx),
normalizeResolvedModel: (ctx) => {
if (normalizeProviderId(ctx.provider) !== PROVIDER_ID) {

View File

@ -27,6 +27,7 @@ import {
type ResolvedSignalAccount,
} from "openclaw/plugin-sdk/signal";
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
import type { SignalProbe } from "./probe.js";
import { getSignalRuntime } from "./runtime.js";
import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js";
@ -220,6 +221,10 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount> = {
const baseUrl = account.baseUrl;
return await getSignalRuntime().channel.signal.probeSignal(baseUrl, timeoutMs);
},
formatCapabilitiesProbe: ({ probe }) =>
(probe as SignalProbe | undefined)?.version
? [{ text: `Signal daemon: ${(probe as SignalProbe).version}` }]
: [],
buildAccountSnapshot: ({ account, runtime, probe }) => ({
...buildBaseAccountStatusSnapshot({ account, runtime, probe }),
baseUrl: account.baseUrl,

View File

@ -36,7 +36,10 @@ import {
} from "openclaw/plugin-sdk/slack";
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
import { buildPassiveProbedChannelStatusSummary } from "../../shared/channel-status-summary.js";
import { parseSlackBlocksInput } from "./blocks-input.js";
import type { SlackProbe } from "./probe.js";
import { getSlackRuntime } from "./runtime.js";
import { fetchSlackScopes } from "./scopes.js";
import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js";
import { parseSlackTarget } from "./targets.js";
@ -126,6 +129,21 @@ function resolveSlackAutoThreadId(params: {
return context.currentThreadTs;
}
function formatSlackScopeDiagnostic(params: {
tokenType: "bot" | "user";
result: Awaited<ReturnType<typeof fetchSlackScopes>>;
}) {
const source = params.result.source ? ` (${params.result.source})` : "";
const label = params.tokenType === "user" ? "User scopes" : "Bot scopes";
if (params.result.ok && params.result.scopes?.length) {
return { text: `${label}${source}: ${params.result.scopes.join(", ")}` } as const;
}
return {
text: `${label}: ${params.result.error ?? "scope lookup failed"}`,
tone: "error",
} as const;
}
const slackConfigAccessors = createScopedAccountConfigAccessors({
resolveAccount: ({ cfg, accountId }) => resolveSlackAccount({ cfg, accountId }),
resolveAllowFrom: (account: ResolvedSlackAccount) => account.dm?.allowFrom,
@ -285,6 +303,17 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
normalizeTarget: normalizeSlackMessagingTarget,
enableInteractiveReplies: ({ cfg, accountId }) =>
isSlackInteractiveRepliesEnabled({ cfg, accountId }),
hasStructuredReplyPayload: ({ payload }) => {
const slackData = payload.channelData?.slack;
if (!slackData || typeof slackData !== "object" || Array.isArray(slackData)) {
return false;
}
try {
return Boolean(parseSlackBlocksInput((slackData as { blocks?: unknown }).blocks)?.length);
} catch {
return false;
}
},
targetResolver: {
looksLikeId: looksLikeSlackTargetId,
hint: "<channelId|user:ID|channel:ID>",
@ -429,6 +458,35 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
}
return await getSlackRuntime().channel.slack.probeSlack(token, timeoutMs);
},
formatCapabilitiesProbe: ({ probe }) => {
const slackProbe = probe as SlackProbe | undefined;
const lines = [];
if (slackProbe?.bot?.name) {
lines.push({ text: `Bot: @${slackProbe.bot.name}` });
}
if (slackProbe?.team?.name || slackProbe?.team?.id) {
const id = slackProbe.team?.id ? ` (${slackProbe.team.id})` : "";
lines.push({ text: `Team: ${slackProbe.team?.name ?? "unknown"}${id}` });
}
return lines;
},
buildCapabilitiesDiagnostics: async ({ account, timeoutMs }) => {
const lines = [];
const details: Record<string, unknown> = {};
const botToken = account.botToken?.trim();
const userToken = account.config.userToken?.trim();
const botScopes = botToken
? await fetchSlackScopes(botToken, timeoutMs)
: { ok: false, error: "Slack bot token missing." };
lines.push(formatSlackScopeDiagnostic({ tokenType: "bot", result: botScopes }));
details.botScopes = botScopes;
if (userToken) {
const userScopes = await fetchSlackScopes(userToken, timeoutMs);
lines.push(formatSlackScopeDiagnostic({ tokenType: "user", result: userScopes }));
details.userScopes = userScopes;
}
return { lines, details };
},
buildAccountSnapshot: ({ account, runtime, probe }) => {
const mode = account.config.mode ?? "socket";
const configured =

View File

@ -0,0 +1,773 @@
import type { SlackActionMiddlewareArgs } from "@slack/bolt";
import type { Block, KnownBlock } from "@slack/web-api";
import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js";
import {
buildPluginBindingResolvedText,
parsePluginBindingApprovalCustomId,
resolvePluginConversationBindingApproval,
} from "../../../../../src/plugins/conversation-binding.js";
import { dispatchPluginInteractiveHandler } from "../../../../../src/plugins/interactive.js";
import { SLACK_REPLY_BUTTON_ACTION_ID, SLACK_REPLY_SELECT_ACTION_ID } from "../../blocks-render.js";
import { authorizeSlackSystemEventSender } from "../auth.js";
import type { SlackMonitorContext } from "../context.js";
import { escapeSlackMrkdwn } from "../mrkdwn.js";
type InteractionMessageBlock = {
type?: string;
block_id?: string;
elements?: Array<{ action_id?: string }>;
};
type SelectOption = {
value?: string;
text?: { text?: string };
};
type InteractionSelectionFields = {
blockId?: string;
callbackId?: string;
value?: string;
inputKind?: "number" | "text" | "url" | "email" | "rich_text";
inputValue?: string;
inputNumber?: number;
inputEmail?: string;
inputUrl?: string;
richTextValue?: unknown;
richTextPreview?: string;
selectedValues?: string[];
selectedUsers?: string[];
selectedChannels?: string[];
selectedConversations?: string[];
selectedLabels?: string[];
selectedDate?: string;
selectedTime?: string;
selectedDateTime?: number;
actionType?: string;
viewId?: string;
privateMetadata?: string;
viewHash?: string;
inputs?: unknown[];
isCleared?: boolean;
routedChannelType?: string;
routedChannelId?: string;
};
export type InteractionSummary = InteractionSelectionFields & {
interactionType?: "block_action" | "view_submission" | "view_closed";
actionId: string;
userId?: string;
teamId?: string;
triggerId?: string;
responseUrl?: string;
workflowTriggerUrl?: string;
workflowId?: string;
channelId?: string;
messageTs?: string;
threadTs?: string;
};
type SlackActionSummary = Omit<InteractionSummary, "actionId" | "blockId">;
type SlackBlockActionBody = {
user?: { id?: string };
team?: { id?: string };
trigger_id?: string;
response_url?: string;
channel?: { id?: string };
container?: { channel_id?: string; message_ts?: string; thread_ts?: string };
message?: { ts?: string; text?: string; blocks?: unknown[] };
};
type SlackBlockActionRespond = NonNullable<SlackActionMiddlewareArgs["respond"]>;
type ParsedSlackBlockAction = {
typedBody: SlackBlockActionBody;
typedAction: Record<string, unknown>;
typedActionWithText: {
action_id?: string;
block_id?: string;
type?: string;
text?: { text?: string };
};
actionId: string;
blockId?: string;
userId: string;
channelId?: string;
messageTs?: string;
threadTs?: string;
actionSummary: SlackActionSummary;
};
function readOptionValues(options: unknown): string[] | undefined {
if (!Array.isArray(options)) {
return undefined;
}
const values = options
.map((option) => (option && typeof option === "object" ? (option as SelectOption).value : null))
.filter((value): value is string => typeof value === "string" && value.trim().length > 0);
return values.length > 0 ? values : undefined;
}
function readOptionLabels(options: unknown): string[] | undefined {
if (!Array.isArray(options)) {
return undefined;
}
const labels = options
.map((option) =>
option && typeof option === "object" ? ((option as SelectOption).text?.text ?? null) : null,
)
.filter((label): label is string => typeof label === "string" && label.trim().length > 0);
return labels.length > 0 ? labels : undefined;
}
function uniqueNonEmptyStrings(values: string[]): string[] {
const unique: string[] = [];
const seen = new Set<string>();
for (const entry of values) {
if (typeof entry !== "string") {
continue;
}
const trimmed = entry.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
unique.push(trimmed);
}
return unique;
}
function collectRichTextFragments(value: unknown, out: string[]): void {
if (!value || typeof value !== "object") {
return;
}
const typed = value as { text?: unknown; elements?: unknown };
if (typeof typed.text === "string" && typed.text.trim().length > 0) {
out.push(typed.text.trim());
}
if (Array.isArray(typed.elements)) {
for (const child of typed.elements) {
collectRichTextFragments(child, out);
}
}
}
function summarizeRichTextPreview(value: unknown): string | undefined {
const fragments: string[] = [];
collectRichTextFragments(value, fragments);
if (fragments.length === 0) {
return undefined;
}
const joined = fragments.join(" ").replace(/\s+/g, " ").trim();
if (!joined) {
return undefined;
}
const max = 120;
return joined.length <= max ? joined : `${joined.slice(0, max - 1)}`;
}
function readInteractionAction(raw: unknown) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return undefined;
}
return raw as Record<string, unknown>;
}
export function summarizeAction(action: Record<string, unknown>): SlackActionSummary {
const typed = action as {
type?: string;
selected_option?: SelectOption;
selected_options?: SelectOption[];
selected_user?: string;
selected_users?: string[];
selected_channel?: string;
selected_channels?: string[];
selected_conversation?: string;
selected_conversations?: string[];
selected_date?: string;
selected_time?: string;
selected_date_time?: number;
value?: string;
rich_text_value?: unknown;
workflow?: {
trigger_url?: string;
workflow_id?: string;
};
};
const actionType = typed.type;
const selectedUsers = uniqueNonEmptyStrings([
...(typed.selected_user ? [typed.selected_user] : []),
...(Array.isArray(typed.selected_users) ? typed.selected_users : []),
]);
const selectedChannels = uniqueNonEmptyStrings([
...(typed.selected_channel ? [typed.selected_channel] : []),
...(Array.isArray(typed.selected_channels) ? typed.selected_channels : []),
]);
const selectedConversations = uniqueNonEmptyStrings([
...(typed.selected_conversation ? [typed.selected_conversation] : []),
...(Array.isArray(typed.selected_conversations) ? typed.selected_conversations : []),
]);
const selectedValues = uniqueNonEmptyStrings([
...(typed.selected_option?.value ? [typed.selected_option.value] : []),
...(readOptionValues(typed.selected_options) ?? []),
...selectedUsers,
...selectedChannels,
...selectedConversations,
]);
const selectedLabels = uniqueNonEmptyStrings([
...(typed.selected_option?.text?.text ? [typed.selected_option.text.text] : []),
...(readOptionLabels(typed.selected_options) ?? []),
]);
const inputValue = typeof typed.value === "string" ? typed.value : undefined;
const inputNumber =
actionType === "number_input" && inputValue != null ? Number.parseFloat(inputValue) : undefined;
const parsedNumber = Number.isFinite(inputNumber) ? inputNumber : undefined;
const inputEmail =
actionType === "email_text_input" && inputValue?.includes("@") ? inputValue : undefined;
let inputUrl: string | undefined;
if (actionType === "url_text_input" && inputValue) {
try {
inputUrl = new URL(inputValue).toString();
} catch {
inputUrl = undefined;
}
}
const richTextValue = actionType === "rich_text_input" ? typed.rich_text_value : undefined;
const richTextPreview = summarizeRichTextPreview(richTextValue);
const inputKind =
actionType === "number_input"
? "number"
: actionType === "email_text_input"
? "email"
: actionType === "url_text_input"
? "url"
: actionType === "rich_text_input"
? "rich_text"
: inputValue != null
? "text"
: undefined;
return {
actionType,
inputKind,
value: typed.value,
selectedValues: selectedValues.length > 0 ? selectedValues : undefined,
selectedUsers: selectedUsers.length > 0 ? selectedUsers : undefined,
selectedChannels: selectedChannels.length > 0 ? selectedChannels : undefined,
selectedConversations: selectedConversations.length > 0 ? selectedConversations : undefined,
selectedLabels: selectedLabels.length > 0 ? selectedLabels : undefined,
selectedDate: typed.selected_date,
selectedTime: typed.selected_time,
selectedDateTime:
typeof typed.selected_date_time === "number" ? typed.selected_date_time : undefined,
inputValue,
inputNumber: parsedNumber,
inputEmail,
inputUrl,
richTextValue,
richTextPreview,
workflowTriggerUrl: typed.workflow?.trigger_url,
workflowId: typed.workflow?.workflow_id,
};
}
function isBulkActionsBlock(block: InteractionMessageBlock): boolean {
return (
block.type === "actions" &&
Array.isArray(block.elements) &&
block.elements.length > 0 &&
block.elements.every((el) => typeof el.action_id === "string" && el.action_id.includes("_all_"))
);
}
function formatInteractionSelectionLabel(params: {
actionId: string;
summary: SlackActionSummary;
buttonText?: string;
}): string {
if (params.summary.actionType === "button" && params.buttonText?.trim()) {
return params.buttonText.trim();
}
if (params.summary.selectedLabels?.length) {
if (params.summary.selectedLabels.length <= 3) {
return params.summary.selectedLabels.join(", ");
}
return `${params.summary.selectedLabels.slice(0, 3).join(", ")} +${
params.summary.selectedLabels.length - 3
}`;
}
if (params.summary.selectedValues?.length) {
if (params.summary.selectedValues.length <= 3) {
return params.summary.selectedValues.join(", ");
}
return `${params.summary.selectedValues.slice(0, 3).join(", ")} +${
params.summary.selectedValues.length - 3
}`;
}
if (params.summary.selectedDate) {
return params.summary.selectedDate;
}
if (params.summary.selectedTime) {
return params.summary.selectedTime;
}
if (typeof params.summary.selectedDateTime === "number") {
return new Date(params.summary.selectedDateTime * 1000).toISOString();
}
if (params.summary.richTextPreview) {
return params.summary.richTextPreview;
}
if (params.summary.value?.trim()) {
return params.summary.value.trim();
}
return params.actionId;
}
function formatInteractionConfirmationText(params: {
selectedLabel: string;
userId?: string;
}): string {
const actor = params.userId?.trim() ? ` by <@${params.userId.trim()}>` : "";
return `:white_check_mark: *${escapeSlackMrkdwn(params.selectedLabel)}* selected${actor}`;
}
function buildSlackPluginInteractionData(params: {
actionId: string;
summary: SlackActionSummary;
}): string | null {
const actionId = params.actionId.trim();
if (!actionId) {
return null;
}
const payload =
params.summary.value?.trim() ||
params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) ||
"";
if (actionId === SLACK_REPLY_BUTTON_ACTION_ID || actionId === SLACK_REPLY_SELECT_ACTION_ID) {
return payload || null;
}
return payload ? `${actionId}:${payload}` : actionId;
}
function buildSlackPluginInteractionId(params: {
userId?: string;
channelId?: string;
messageTs?: string;
triggerId?: string;
actionId: string;
summary: SlackActionSummary;
}): string {
const primaryValue =
params.summary.value?.trim() ||
params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) ||
"";
return [
params.userId?.trim() || "",
params.channelId?.trim() || "",
params.messageTs?.trim() || "",
params.triggerId?.trim() || "",
params.actionId.trim(),
primaryValue,
].join(":");
}
function parseSlackBlockAction(params: {
body: unknown;
action: unknown;
log?: (message: string) => void;
}): ParsedSlackBlockAction | null {
const typedBody = params.body as SlackBlockActionBody;
const typedAction = readInteractionAction(params.action);
if (!typedAction) {
params.log?.(
`slack:interaction malformed action payload channel=${typedBody.channel?.id ?? typedBody.container?.channel_id ?? "unknown"} user=${
typedBody.user?.id ?? "unknown"
}`,
);
return null;
}
const typedActionWithText = typedAction as {
action_id?: string;
block_id?: string;
type?: string;
text?: { text?: string };
};
return {
typedBody,
typedAction,
typedActionWithText,
actionId:
typeof typedActionWithText.action_id === "string" ? typedActionWithText.action_id : "unknown",
blockId: typedActionWithText.block_id,
userId: typedBody.user?.id ?? "unknown",
channelId: typedBody.channel?.id ?? typedBody.container?.channel_id,
messageTs: typedBody.message?.ts ?? typedBody.container?.message_ts,
threadTs: typedBody.container?.thread_ts,
actionSummary: summarizeAction(typedAction),
};
}
async function respondEphemeral(
respond: SlackBlockActionRespond | undefined,
text: string,
): Promise<void> {
if (!respond) {
return;
}
try {
await respond({
text,
response_type: "ephemeral",
});
} catch {
// Best-effort feedback only.
}
}
async function updateSlackInteractionMessage(params: {
ctx: SlackMonitorContext;
channelId?: string;
messageTs?: string;
text: string;
blocks?: (Block | KnownBlock)[];
}): Promise<void> {
if (!params.channelId || !params.messageTs) {
return;
}
await params.ctx.app.client.chat.update({
channel: params.channelId,
ts: params.messageTs,
text: params.text,
...(params.blocks ? { blocks: params.blocks } : {}),
});
}
async function authorizeSlackBlockAction(params: {
ctx: SlackMonitorContext;
parsed: ParsedSlackBlockAction;
respond?: SlackBlockActionRespond;
}): Promise<
| {
allowed: true;
channelType?: "im" | "mpim" | "channel" | "group";
}
| { allowed: false }
> {
const auth = await authorizeSlackSystemEventSender({
ctx: params.ctx,
senderId: params.parsed.userId,
channelId: params.parsed.channelId,
});
if (auth.allowed) {
return auth;
}
params.ctx.runtime.log?.(
`slack:interaction drop action=${params.parsed.actionId} user=${params.parsed.userId} channel=${params.parsed.channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`,
);
await respondEphemeral(params.respond, "You are not authorized to use this control.");
return { allowed: false };
}
async function handleSlackPluginBindingApproval(params: {
ctx: SlackMonitorContext;
parsed: ParsedSlackBlockAction;
pluginInteractionData: string;
respond?: SlackBlockActionRespond;
}): Promise<boolean> {
const pluginBindingApproval = parsePluginBindingApprovalCustomId(params.pluginInteractionData);
if (!pluginBindingApproval) {
return false;
}
const resolved = await resolvePluginConversationBindingApproval({
approvalId: pluginBindingApproval.approvalId,
decision: pluginBindingApproval.decision,
senderId: params.parsed.userId,
});
try {
await updateSlackInteractionMessage({
ctx: params.ctx,
channelId: params.parsed.channelId,
messageTs: params.parsed.messageTs,
text: params.parsed.typedBody.message?.text ?? "",
blocks: [],
});
} catch {
// Best-effort cleanup only; continue with follow-up feedback.
}
await respondEphemeral(params.respond, buildPluginBindingResolvedText(resolved));
return true;
}
async function dispatchSlackPluginInteraction(params: {
ctx: SlackMonitorContext;
parsed: ParsedSlackBlockAction;
pluginInteractionData: string;
auth: { isAuthorizedSender: boolean };
respond?: SlackBlockActionRespond;
}): Promise<boolean> {
const pluginInteractionId = buildSlackPluginInteractionId({
userId: params.parsed.userId,
channelId: params.parsed.channelId,
messageTs: params.parsed.messageTs,
triggerId: params.parsed.typedBody.trigger_id,
actionId: params.parsed.actionId,
summary: params.parsed.actionSummary,
});
if (
await handleSlackPluginBindingApproval({
ctx: params.ctx,
parsed: params.parsed,
pluginInteractionData: params.pluginInteractionData,
respond: params.respond,
})
) {
return true;
}
const pluginResult = await dispatchPluginInteractiveHandler({
channel: "slack",
data: params.pluginInteractionData,
interactionId: pluginInteractionId,
ctx: {
accountId: params.ctx.accountId,
interactionId: pluginInteractionId,
conversationId: params.parsed.channelId ?? "",
parentConversationId: undefined,
threadId: params.parsed.threadTs,
senderId: params.parsed.userId,
senderUsername: undefined,
auth: params.auth,
interaction: {
kind: params.parsed.actionSummary.actionType === "button" ? "button" : "select",
actionId: params.parsed.actionId,
blockId: params.parsed.blockId,
messageTs: params.parsed.messageTs,
threadTs: params.parsed.threadTs,
value: params.parsed.actionSummary.value,
selectedValues: params.parsed.actionSummary.selectedValues,
selectedLabels: params.parsed.actionSummary.selectedLabels,
triggerId: params.parsed.typedBody.trigger_id,
responseUrl: params.parsed.typedBody.response_url,
},
},
respond: {
acknowledge: async () => {},
reply: async ({ text, responseType }) => {
if (!text) {
return;
}
await params.respond?.({
text,
response_type: responseType ?? "ephemeral",
});
},
followUp: async ({ text, responseType }) => {
if (!text) {
return;
}
await params.respond?.({
text,
response_type: responseType ?? "ephemeral",
});
},
editMessage: async ({ text, blocks }) => {
await updateSlackInteractionMessage({
ctx: params.ctx,
channelId: params.parsed.channelId,
messageTs: params.parsed.messageTs,
text: text ?? params.parsed.typedBody.message?.text ?? "",
blocks: Array.isArray(blocks) ? (blocks as (Block | KnownBlock)[]) : undefined,
});
},
},
});
return pluginResult.matched && pluginResult.handled;
}
function enqueueSlackBlockActionEvent(params: {
ctx: SlackMonitorContext;
parsed: ParsedSlackBlockAction;
auth: { channelType?: "im" | "mpim" | "channel" | "group" };
formatSystemEvent: (payload: Record<string, unknown>) => string;
}): void {
const eventPayload: InteractionSummary = {
interactionType: "block_action",
actionId: params.parsed.actionId,
blockId: params.parsed.blockId,
...params.parsed.actionSummary,
userId: params.parsed.userId,
teamId: params.parsed.typedBody.team?.id,
triggerId: params.parsed.typedBody.trigger_id,
responseUrl: params.parsed.typedBody.response_url,
channelId: params.parsed.channelId,
messageTs: params.parsed.messageTs,
threadTs: params.parsed.threadTs,
};
params.ctx.runtime.log?.(
`slack:interaction action=${params.parsed.actionId} type=${params.parsed.actionSummary.actionType ?? "unknown"} user=${params.parsed.userId} channel=${params.parsed.channelId}`,
);
const sessionKey = params.ctx.resolveSlackSystemEventSessionKey({
channelId: params.parsed.channelId,
channelType: params.auth.channelType,
senderId: params.parsed.userId,
});
const contextParts = [
"slack:interaction",
params.parsed.channelId,
params.parsed.messageTs,
params.parsed.actionId,
].filter(Boolean);
enqueueSystemEvent(params.formatSystemEvent(eventPayload), {
sessionKey,
contextKey: contextParts.join(":"),
});
}
function buildSlackConfirmationBlocks(params: {
parsed: ParsedSlackBlockAction;
originalBlocks: unknown[];
}): (Block | KnownBlock)[] {
const selectedLabel = formatInteractionSelectionLabel({
actionId: params.parsed.actionId,
summary: params.parsed.actionSummary,
buttonText: params.parsed.typedActionWithText.text?.text,
});
let updatedBlocks = params.originalBlocks.map((block) => {
const typedBlock = block as InteractionMessageBlock;
if (typedBlock.type === "actions" && typedBlock.block_id === params.parsed.blockId) {
return {
type: "context",
elements: [
{
type: "mrkdwn",
text: formatInteractionConfirmationText({
selectedLabel,
userId: params.parsed.userId,
}),
},
],
};
}
return block;
});
const hasRemainingIndividualActionRows = updatedBlocks.some((block) => {
const typedBlock = block as InteractionMessageBlock;
return typedBlock.type === "actions" && !isBulkActionsBlock(typedBlock);
});
if (!hasRemainingIndividualActionRows) {
updatedBlocks = updatedBlocks.filter((block, index) => {
const typedBlock = block as InteractionMessageBlock;
if (isBulkActionsBlock(typedBlock)) {
return false;
}
if (typedBlock.type !== "divider") {
return true;
}
const next = updatedBlocks[index + 1] as InteractionMessageBlock | undefined;
return !next || !isBulkActionsBlock(next);
});
}
return updatedBlocks as (Block | KnownBlock)[];
}
async function updateSlackLegacyBlockAction(params: {
ctx: SlackMonitorContext;
parsed: ParsedSlackBlockAction;
respond?: SlackBlockActionRespond;
}): Promise<void> {
const originalBlocks = params.parsed.typedBody.message?.blocks;
if (
!Array.isArray(originalBlocks) ||
!params.parsed.channelId ||
!params.parsed.messageTs ||
!params.parsed.blockId
) {
return;
}
try {
await updateSlackInteractionMessage({
ctx: params.ctx,
channelId: params.parsed.channelId,
messageTs: params.parsed.messageTs,
text: params.parsed.typedBody.message?.text ?? "",
blocks: buildSlackConfirmationBlocks({
parsed: params.parsed,
originalBlocks,
}),
});
} catch {
await respondEphemeral(params.respond, `Button "${params.parsed.actionId}" clicked!`);
}
}
async function handleSlackBlockAction(params: {
ctx: SlackMonitorContext;
args: SlackActionMiddlewareArgs;
formatSystemEvent: (payload: Record<string, unknown>) => string;
}): Promise<void> {
const { ack, body, action, respond } = params.args;
await ack();
if (params.ctx.shouldDropMismatchedSlackEvent?.(body)) {
params.ctx.runtime.log?.("slack:interaction drop block action payload (mismatched app/team)");
return;
}
const parsed = parseSlackBlockAction({
body,
action,
log: params.ctx.runtime.log,
});
if (!parsed) {
return;
}
const auth = await authorizeSlackBlockAction({
ctx: params.ctx,
parsed,
respond,
});
if (!auth.allowed) {
return;
}
const pluginInteractionData = buildSlackPluginInteractionData({
actionId: parsed.actionId,
summary: parsed.actionSummary,
});
if (pluginInteractionData) {
const handled = await dispatchSlackPluginInteraction({
ctx: params.ctx,
parsed,
pluginInteractionData,
auth: {
isAuthorizedSender: true,
},
respond,
});
if (handled) {
return;
}
}
enqueueSlackBlockActionEvent({
ctx: params.ctx,
parsed,
auth,
formatSystemEvent: params.formatSystemEvent,
});
await updateSlackLegacyBlockAction({
ctx: params.ctx,
parsed,
respond,
});
}
export function registerSlackBlockActionHandler(params: {
ctx: SlackMonitorContext;
formatSystemEvent: (payload: Record<string, unknown>) => string;
}): void {
if (typeof params.ctx.app.action !== "function") {
return;
}
params.ctx.app.action(/.+/, async (args: SlackActionMiddlewareArgs) => {
await handleSlackBlockAction({
ctx: params.ctx,
args,
formatSystemEvent: params.formatSystemEvent,
});
});
}

View File

@ -1,17 +1,10 @@
import type { SlackActionMiddlewareArgs } from "@slack/bolt";
import type { Block, KnownBlock } from "@slack/web-api";
import { enqueueSystemEvent } from "../../../../../src/infra/system-events.js";
import {
buildPluginBindingResolvedText,
parsePluginBindingApprovalCustomId,
resolvePluginConversationBindingApproval,
} from "../../../../../src/plugins/conversation-binding.js";
import { dispatchPluginInteractiveHandler } from "../../../../../src/plugins/interactive.js";
import { SLACK_REPLY_BUTTON_ACTION_ID, SLACK_REPLY_SELECT_ACTION_ID } from "../../blocks-render.js";
import { truncateSlackText } from "../../truncate.js";
import { authorizeSlackSystemEventSender } from "../auth.js";
import type { SlackMonitorContext } from "../context.js";
import { escapeSlackMrkdwn } from "../mrkdwn.js";
import {
registerSlackBlockActionHandler,
summarizeAction,
type InteractionSummary,
} from "./interactions.block-actions.js";
import {
registerModalLifecycleHandler,
type ModalInputSummary,
@ -34,33 +27,6 @@ const SLACK_INTERACTION_REDACTED_KEYS = new Set([
"viewHash",
]);
type InteractionMessageBlock = {
type?: string;
block_id?: string;
elements?: Array<{ action_id?: string }>;
};
type SelectOption = {
value?: string;
text?: { text?: string };
};
type InteractionSelectionFields = Partial<ModalInputSummary>;
type InteractionSummary = InteractionSelectionFields & {
interactionType?: "block_action" | "view_submission" | "view_closed";
actionId: string;
userId?: string;
teamId?: string;
triggerId?: string;
responseUrl?: string;
workflowTriggerUrl?: string;
workflowId?: string;
channelId?: string;
messageTs?: string;
threadTs?: string;
};
function sanitizeSlackInteractionPayloadValue(value: unknown, key?: string): unknown {
if (value === undefined) {
return undefined;
@ -189,281 +155,6 @@ function formatSlackInteractionSystemEvent(payload: Record<string, unknown>): st
});
}
function readOptionValues(options: unknown): string[] | undefined {
if (!Array.isArray(options)) {
return undefined;
}
const values = options
.map((option) => (option && typeof option === "object" ? (option as SelectOption).value : null))
.filter((value): value is string => typeof value === "string" && value.trim().length > 0);
return values.length > 0 ? values : undefined;
}
function readOptionLabels(options: unknown): string[] | undefined {
if (!Array.isArray(options)) {
return undefined;
}
const labels = options
.map((option) =>
option && typeof option === "object" ? ((option as SelectOption).text?.text ?? null) : null,
)
.filter((label): label is string => typeof label === "string" && label.trim().length > 0);
return labels.length > 0 ? labels : undefined;
}
function uniqueNonEmptyStrings(values: string[]): string[] {
const unique: string[] = [];
const seen = new Set<string>();
for (const entry of values) {
if (typeof entry !== "string") {
continue;
}
const trimmed = entry.trim();
if (!trimmed || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
unique.push(trimmed);
}
return unique;
}
function collectRichTextFragments(value: unknown, out: string[]): void {
if (!value || typeof value !== "object") {
return;
}
const typed = value as { text?: unknown; elements?: unknown };
if (typeof typed.text === "string" && typed.text.trim().length > 0) {
out.push(typed.text.trim());
}
if (Array.isArray(typed.elements)) {
for (const child of typed.elements) {
collectRichTextFragments(child, out);
}
}
}
function summarizeRichTextPreview(value: unknown): string | undefined {
const fragments: string[] = [];
collectRichTextFragments(value, fragments);
if (fragments.length === 0) {
return undefined;
}
const joined = fragments.join(" ").replace(/\s+/g, " ").trim();
if (!joined) {
return undefined;
}
const max = 120;
return joined.length <= max ? joined : `${joined.slice(0, max - 1)}`;
}
function readInteractionAction(raw: unknown) {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
return undefined;
}
return raw as Record<string, unknown>;
}
function summarizeAction(
action: Record<string, unknown>,
): Omit<InteractionSummary, "actionId" | "blockId"> {
const typed = action as {
type?: string;
selected_option?: SelectOption;
selected_options?: SelectOption[];
selected_user?: string;
selected_users?: string[];
selected_channel?: string;
selected_channels?: string[];
selected_conversation?: string;
selected_conversations?: string[];
selected_date?: string;
selected_time?: string;
selected_date_time?: number;
value?: string;
rich_text_value?: unknown;
workflow?: {
trigger_url?: string;
workflow_id?: string;
};
};
const actionType = typed.type;
const selectedUsers = uniqueNonEmptyStrings([
...(typed.selected_user ? [typed.selected_user] : []),
...(Array.isArray(typed.selected_users) ? typed.selected_users : []),
]);
const selectedChannels = uniqueNonEmptyStrings([
...(typed.selected_channel ? [typed.selected_channel] : []),
...(Array.isArray(typed.selected_channels) ? typed.selected_channels : []),
]);
const selectedConversations = uniqueNonEmptyStrings([
...(typed.selected_conversation ? [typed.selected_conversation] : []),
...(Array.isArray(typed.selected_conversations) ? typed.selected_conversations : []),
]);
const selectedValues = uniqueNonEmptyStrings([
...(typed.selected_option?.value ? [typed.selected_option.value] : []),
...(readOptionValues(typed.selected_options) ?? []),
...selectedUsers,
...selectedChannels,
...selectedConversations,
]);
const selectedLabels = uniqueNonEmptyStrings([
...(typed.selected_option?.text?.text ? [typed.selected_option.text.text] : []),
...(readOptionLabels(typed.selected_options) ?? []),
]);
const inputValue = typeof typed.value === "string" ? typed.value : undefined;
const inputNumber =
actionType === "number_input" && inputValue != null ? Number.parseFloat(inputValue) : undefined;
const parsedNumber = Number.isFinite(inputNumber) ? inputNumber : undefined;
const inputEmail =
actionType === "email_text_input" && inputValue?.includes("@") ? inputValue : undefined;
let inputUrl: string | undefined;
if (actionType === "url_text_input" && inputValue) {
try {
// Normalize to a canonical URL string so downstream handlers do not need to reparse.
inputUrl = new URL(inputValue).toString();
} catch {
inputUrl = undefined;
}
}
const richTextValue = actionType === "rich_text_input" ? typed.rich_text_value : undefined;
const richTextPreview = summarizeRichTextPreview(richTextValue);
const inputKind =
actionType === "number_input"
? "number"
: actionType === "email_text_input"
? "email"
: actionType === "url_text_input"
? "url"
: actionType === "rich_text_input"
? "rich_text"
: inputValue != null
? "text"
: undefined;
return {
actionType,
inputKind,
value: typed.value,
selectedValues: selectedValues.length > 0 ? selectedValues : undefined,
selectedUsers: selectedUsers.length > 0 ? selectedUsers : undefined,
selectedChannels: selectedChannels.length > 0 ? selectedChannels : undefined,
selectedConversations: selectedConversations.length > 0 ? selectedConversations : undefined,
selectedLabels: selectedLabels.length > 0 ? selectedLabels : undefined,
selectedDate: typed.selected_date,
selectedTime: typed.selected_time,
selectedDateTime:
typeof typed.selected_date_time === "number" ? typed.selected_date_time : undefined,
inputValue,
inputNumber: parsedNumber,
inputEmail,
inputUrl,
richTextValue,
richTextPreview,
workflowTriggerUrl: typed.workflow?.trigger_url,
workflowId: typed.workflow?.workflow_id,
};
}
function isBulkActionsBlock(block: InteractionMessageBlock): boolean {
return (
block.type === "actions" &&
Array.isArray(block.elements) &&
block.elements.length > 0 &&
block.elements.every((el) => typeof el.action_id === "string" && el.action_id.includes("_all_"))
);
}
function formatInteractionSelectionLabel(params: {
actionId: string;
summary: Omit<InteractionSummary, "actionId" | "blockId">;
buttonText?: string;
}): string {
if (params.summary.actionType === "button" && params.buttonText?.trim()) {
return params.buttonText.trim();
}
if (params.summary.selectedLabels?.length) {
if (params.summary.selectedLabels.length <= 3) {
return params.summary.selectedLabels.join(", ");
}
return `${params.summary.selectedLabels.slice(0, 3).join(", ")} +${
params.summary.selectedLabels.length - 3
}`;
}
if (params.summary.selectedValues?.length) {
if (params.summary.selectedValues.length <= 3) {
return params.summary.selectedValues.join(", ");
}
return `${params.summary.selectedValues.slice(0, 3).join(", ")} +${
params.summary.selectedValues.length - 3
}`;
}
if (params.summary.selectedDate) {
return params.summary.selectedDate;
}
if (params.summary.selectedTime) {
return params.summary.selectedTime;
}
if (typeof params.summary.selectedDateTime === "number") {
return new Date(params.summary.selectedDateTime * 1000).toISOString();
}
if (params.summary.richTextPreview) {
return params.summary.richTextPreview;
}
if (params.summary.value?.trim()) {
return params.summary.value.trim();
}
return params.actionId;
}
function formatInteractionConfirmationText(params: {
selectedLabel: string;
userId?: string;
}): string {
const actor = params.userId?.trim() ? ` by <@${params.userId.trim()}>` : "";
return `:white_check_mark: *${escapeSlackMrkdwn(params.selectedLabel)}* selected${actor}`;
}
function buildSlackPluginInteractionData(params: {
actionId: string;
summary: Omit<InteractionSummary, "actionId" | "blockId">;
}): string | null {
const actionId = params.actionId.trim();
if (!actionId) {
return null;
}
const payload =
params.summary.value?.trim() ||
params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) ||
"";
if (actionId === SLACK_REPLY_BUTTON_ACTION_ID || actionId === SLACK_REPLY_SELECT_ACTION_ID) {
return payload || null;
}
return payload ? `${actionId}:${payload}` : actionId;
}
function buildSlackPluginInteractionId(params: {
userId?: string;
channelId?: string;
messageTs?: string;
triggerId?: string;
actionId: string;
summary: Omit<InteractionSummary, "actionId" | "blockId">;
}): string {
const primaryValue =
params.summary.value?.trim() ||
params.summary.selectedValues?.map((value) => value.trim()).find(Boolean) ||
"";
return [
params.userId?.trim() || "",
params.channelId?.trim() || "",
params.messageTs?.trim() || "",
params.triggerId?.trim() || "",
params.actionId.trim(),
primaryValue,
].join(":");
}
function summarizeViewState(values: unknown): ModalInputSummary[] {
if (!values || typeof values !== "object") {
return [];
@ -490,291 +181,9 @@ function summarizeViewState(values: unknown): ModalInputSummary[] {
export function registerSlackInteractionEvents(params: { ctx: SlackMonitorContext }) {
const { ctx } = params;
if (typeof ctx.app.action !== "function") {
return;
}
// Handle Block Kit actions for this Slack app, including legacy/custom
// action_ids that plugin handlers map into shared interactive namespaces.
ctx.app.action(/.+/, async (args: SlackActionMiddlewareArgs) => {
const { ack, body, action, respond } = args;
const typedBody = body as unknown as {
user?: { id?: string };
team?: { id?: string };
trigger_id?: string;
response_url?: string;
channel?: { id?: string };
container?: { channel_id?: string; message_ts?: string; thread_ts?: string };
message?: { ts?: string; text?: string; blocks?: unknown[] };
};
// Acknowledge the action immediately to prevent the warning icon
await ack();
if (ctx.shouldDropMismatchedSlackEvent?.(body)) {
ctx.runtime.log?.("slack:interaction drop block action payload (mismatched app/team)");
return;
}
// Extract action details using proper Bolt types
const typedAction = readInteractionAction(action);
if (!typedAction) {
ctx.runtime.log?.(
`slack:interaction malformed action payload channel=${typedBody.channel?.id ?? typedBody.container?.channel_id ?? "unknown"} user=${
typedBody.user?.id ?? "unknown"
}`,
);
return;
}
const typedActionWithText = typedAction as {
action_id?: string;
block_id?: string;
type?: string;
text?: { text?: string };
};
const actionId =
typeof typedActionWithText.action_id === "string" ? typedActionWithText.action_id : "unknown";
const blockId = typedActionWithText.block_id;
const userId = typedBody.user?.id ?? "unknown";
const channelId = typedBody.channel?.id ?? typedBody.container?.channel_id;
const messageTs = typedBody.message?.ts ?? typedBody.container?.message_ts;
const threadTs = typedBody.container?.thread_ts;
const auth = await authorizeSlackSystemEventSender({
ctx,
senderId: userId,
channelId,
});
if (!auth.allowed) {
ctx.runtime.log?.(
`slack:interaction drop action=${actionId} user=${userId} channel=${channelId ?? "unknown"} reason=${auth.reason ?? "unauthorized"}`,
);
if (respond) {
try {
await respond({
text: "You are not authorized to use this control.",
response_type: "ephemeral",
});
} catch {
// Best-effort feedback only.
}
}
return;
}
const actionSummary = summarizeAction(typedAction);
const pluginInteractionData = buildSlackPluginInteractionData({
actionId,
summary: actionSummary,
});
if (pluginInteractionData) {
const pluginInteractionId = buildSlackPluginInteractionId({
userId,
channelId,
messageTs,
triggerId: typedBody.trigger_id,
actionId,
summary: actionSummary,
});
const pluginBindingApproval = parsePluginBindingApprovalCustomId(pluginInteractionData);
if (pluginBindingApproval) {
const resolved = await resolvePluginConversationBindingApproval({
approvalId: pluginBindingApproval.approvalId,
decision: pluginBindingApproval.decision,
senderId: userId,
});
if (channelId && messageTs) {
try {
await ctx.app.client.chat.update({
channel: channelId,
ts: messageTs,
text: typedBody.message?.text ?? "",
blocks: [],
});
} catch {
// Best-effort cleanup only; continue with follow-up feedback.
}
}
if (respond) {
try {
await respond({
text: buildPluginBindingResolvedText(resolved),
response_type: "ephemeral",
});
} catch {
// Best-effort feedback only.
}
}
return;
}
const pluginResult = await dispatchPluginInteractiveHandler({
channel: "slack",
data: pluginInteractionData,
interactionId: pluginInteractionId,
ctx: {
accountId: ctx.accountId,
interactionId: pluginInteractionId,
conversationId: channelId ?? "",
parentConversationId: undefined,
threadId: threadTs,
senderId: userId,
senderUsername: undefined,
auth: {
isAuthorizedSender: auth.allowed,
},
interaction: {
kind: actionSummary.actionType === "button" ? "button" : "select",
actionId,
blockId,
messageTs,
threadTs,
value: actionSummary.value,
selectedValues: actionSummary.selectedValues,
selectedLabels: actionSummary.selectedLabels,
triggerId: typedBody.trigger_id,
responseUrl: typedBody.response_url,
},
},
respond: {
acknowledge: async () => {},
reply: async ({ text, responseType }) => {
if (!respond) {
return;
}
await respond({
text,
response_type: responseType ?? "ephemeral",
});
},
followUp: async ({ text, responseType }) => {
if (!respond) {
return;
}
await respond({
text,
response_type: responseType ?? "ephemeral",
});
},
editMessage: async ({ text, blocks }) => {
if (!channelId || !messageTs) {
return;
}
await ctx.app.client.chat.update({
channel: channelId,
ts: messageTs,
text: text ?? typedBody.message?.text ?? "",
...(Array.isArray(blocks) ? { blocks: blocks as (Block | KnownBlock)[] } : {}),
});
},
},
});
if (pluginResult.matched && pluginResult.handled) {
return;
}
}
const eventPayload: InteractionSummary = {
interactionType: "block_action",
actionId,
blockId,
...actionSummary,
userId,
teamId: typedBody.team?.id,
triggerId: typedBody.trigger_id,
responseUrl: typedBody.response_url,
channelId,
messageTs,
threadTs,
};
// Log the interaction for debugging
ctx.runtime.log?.(
`slack:interaction action=${actionId} type=${actionSummary.actionType ?? "unknown"} user=${userId} channel=${channelId}`,
);
// Send a system event to notify the agent about the button click
// Pass undefined (not "unknown") to allow proper main session fallback
const sessionKey = ctx.resolveSlackSystemEventSessionKey({
channelId: channelId,
channelType: auth.channelType,
senderId: userId,
});
// Build context key - only include defined values to avoid "unknown" noise
const contextParts = ["slack:interaction", channelId, messageTs, actionId].filter(Boolean);
const contextKey = contextParts.join(":");
enqueueSystemEvent(formatSlackInteractionSystemEvent(eventPayload), {
sessionKey,
contextKey,
});
const originalBlocks = typedBody.message?.blocks;
if (!Array.isArray(originalBlocks) || !channelId || !messageTs) {
return;
}
if (!blockId) {
return;
}
const selectedLabel = formatInteractionSelectionLabel({
actionId,
summary: actionSummary,
buttonText: typedActionWithText.text?.text,
});
let updatedBlocks = originalBlocks.map((block) => {
const typedBlock = block as InteractionMessageBlock;
if (typedBlock.type === "actions" && typedBlock.block_id === blockId) {
return {
type: "context",
elements: [
{
type: "mrkdwn",
text: formatInteractionConfirmationText({ selectedLabel, userId }),
},
],
};
}
return block;
});
const hasRemainingIndividualActionRows = updatedBlocks.some((block) => {
const typedBlock = block as InteractionMessageBlock;
return typedBlock.type === "actions" && !isBulkActionsBlock(typedBlock);
});
if (!hasRemainingIndividualActionRows) {
updatedBlocks = updatedBlocks.filter((block, index) => {
const typedBlock = block as InteractionMessageBlock;
if (isBulkActionsBlock(typedBlock)) {
return false;
}
if (typedBlock.type !== "divider") {
return true;
}
const next = updatedBlocks[index + 1] as InteractionMessageBlock | undefined;
return !next || !isBulkActionsBlock(next);
});
}
try {
await ctx.app.client.chat.update({
channel: channelId,
ts: messageTs,
text: typedBody.message?.text ?? "",
blocks: updatedBlocks as (Block | KnownBlock)[],
});
} catch {
// If update fails, fallback to ephemeral confirmation for immediate UX feedback.
if (!respond) {
return;
}
try {
await respond({
text: `Button "${actionId}" clicked!`,
response_type: "ephemeral",
});
} catch {
// Action was acknowledged and system event enqueued even when response updates fail.
}
}
registerSlackBlockActionHandler({
ctx,
formatSystemEvent: formatSlackInteractionSystemEvent,
});
if (typeof ctx.app.view !== "function") {

View File

@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { buildTelegramInteractiveButtons, resolveTelegramInlineButtons } from "./button-types.js";
describe("buildTelegramInteractiveButtons", () => {
it("maps shared buttons and selects into Telegram inline rows", () => {
expect(
buildTelegramInteractiveButtons({
blocks: [
{
type: "buttons",
buttons: [
{ label: "Approve", value: "approve", style: "success" },
{ label: "Reject", value: "reject", style: "danger" },
{ label: "Later", value: "later" },
{ label: "Archive", value: "archive" },
],
},
{
type: "select",
options: [{ label: "Alpha", value: "alpha" }],
},
],
}),
).toEqual([
[
{ text: "Approve", callback_data: "approve", style: "success" },
{ text: "Reject", callback_data: "reject", style: "danger" },
{ text: "Later", callback_data: "later", style: undefined },
],
[{ text: "Archive", callback_data: "archive", style: undefined }],
[{ text: "Alpha", callback_data: "alpha", style: undefined }],
]);
});
});
describe("resolveTelegramInlineButtons", () => {
it("prefers explicit buttons over shared interactive blocks", () => {
const explicit = [[{ text: "Keep", callback_data: "keep" }]] as const;
expect(
resolveTelegramInlineButtons({
buttons: explicit,
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Override", value: "override" }],
},
],
},
}),
).toBe(explicit);
});
it("derives buttons from raw interactive payloads", () => {
expect(
resolveTelegramInlineButtons({
interactive: {
blocks: [
{
type: "buttons",
buttons: [{ label: "Retry", value: "retry", style: "primary" }],
},
],
},
}),
).toEqual([[{ text: "Retry", callback_data: "retry", style: "primary" }]]);
});
});

View File

@ -1,5 +1,9 @@
import { reduceInteractiveReply } from "../../../src/channels/plugins/outbound/interactive.js";
import type { InteractiveReply, InteractiveReplyButton } from "../../../src/interactive/payload.js";
import {
normalizeInteractiveReply,
type InteractiveReply,
type InteractiveReplyButton,
} from "../../../src/interactive/payload.js";
export type TelegramButtonStyle = "danger" | "success" | "primary";
@ -60,3 +64,12 @@ export function buildTelegramInteractiveButtons(
);
return rows.length > 0 ? rows : undefined;
}
export function resolveTelegramInlineButtons(params: {
buttons?: TelegramInlineButtons;
interactive?: unknown;
}): TelegramInlineButtons | undefined {
return (
params.buttons ?? buildTelegramInteractiveButtons(normalizeInteractiveReply(params.interactive))
);
}

View File

@ -15,7 +15,6 @@ import type {
ChannelMessageActionName,
} from "../../../src/channels/plugins/types.js";
import type { TelegramActionConfig } from "../../../src/config/types.telegram.js";
import { normalizeInteractiveReply } from "../../../src/interactive/payload.js";
import { readBooleanParam } from "../../../src/plugin-sdk/boolean-param.js";
import { extractToolSend } from "../../../src/plugin-sdk/tool-send.js";
import { resolveTelegramPollVisibility } from "../../../src/poll-params.js";
@ -24,7 +23,7 @@ import {
listEnabledTelegramAccounts,
resolveTelegramPollActionGateState,
} from "./accounts.js";
import { buildTelegramInteractiveButtons } from "./button-types.js";
import { resolveTelegramInlineButtons } from "./button-types.js";
import { isTelegramInlineButtonsEnabled } from "./inline-buttons.js";
const providerId = "telegram";
@ -32,9 +31,10 @@ const providerId = "telegram";
function readTelegramSendParams(params: Record<string, unknown>) {
const to = readStringParam(params, "to", { required: true });
const mediaUrl = readStringParam(params, "media", { trim: false });
const buttons =
params.buttons ??
buildTelegramInteractiveButtons(normalizeInteractiveReply(params.interactive));
const buttons = resolveTelegramInlineButtons({
buttons: params.buttons as ReturnType<typeof resolveTelegramInlineButtons>,
interactive: params.interactive,
});
const hasButtons = Array.isArray(buttons) && buttons.length > 0;
const message = readStringParam(params, "message", {
required: !mediaUrl && !hasButtons,

View File

@ -54,7 +54,6 @@ import { sendTypingTelegram } from "./send.js";
import { telegramSetupAdapter } from "./setup-core.js";
import { telegramSetupWizard } from "./setup-surface.js";
import { parseTelegramTarget } from "./targets.js";
import { deleteTelegramUpdateOffset } from "./update-offset-store.js";
type TelegramSendFn = ReturnType<
typeof getTelegramRuntime
@ -334,10 +333,12 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
const previousToken = resolveTelegramAccount({ cfg: prevCfg, accountId }).token.trim();
const nextToken = resolveTelegramAccount({ cfg: nextCfg, accountId }).token.trim();
if (previousToken !== nextToken) {
const { deleteTelegramUpdateOffset } = await import("./update-offset-store.js");
await deleteTelegramUpdateOffset({ accountId });
}
},
onAccountRemoved: async ({ accountId }) => {
const { deleteTelegramUpdateOffset } = await import("./update-offset-store.js");
await deleteTelegramUpdateOffset({ accountId });
},
},
@ -515,6 +516,30 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount, TelegramProb
proxyUrl: account.config.proxy,
network: account.config.network,
}),
formatCapabilitiesProbe: ({ probe }) => {
const lines = [];
if (probe?.bot?.username) {
const botId = probe.bot.id ? ` (${probe.bot.id})` : "";
lines.push({ text: `Bot: @${probe.bot.username}${botId}` });
}
const flags: string[] = [];
if (typeof probe?.bot?.canJoinGroups === "boolean") {
flags.push(`joinGroups=${probe.bot.canJoinGroups}`);
}
if (typeof probe?.bot?.canReadAllGroupMessages === "boolean") {
flags.push(`readAllGroupMessages=${probe.bot.canReadAllGroupMessages}`);
}
if (typeof probe?.bot?.supportsInlineQueries === "boolean") {
flags.push(`inlineQueries=${probe.bot.supportsInlineQueries}`);
}
if (flags.length > 0) {
lines.push({ text: `Flags: ${flags.join(" ")}` });
}
if (probe?.webhook?.url !== undefined) {
lines.push({ text: `Webhook: ${probe.webhook.url || "none"}` });
}
return lines;
},
auditAccount: async ({ account, timeoutMs, probe, cfg }) => {
const groups =
cfg.channels?.telegram?.accounts?.[account.accountId]?.groups ??

View File

@ -10,7 +10,7 @@ import {
} from "../../../src/infra/outbound/send-deps.js";
import { resolveInteractiveTextFallback } from "../../../src/interactive/payload.js";
import type { TelegramInlineButtons } from "./button-types.js";
import { buildTelegramInteractiveButtons } from "./button-types.js";
import { resolveTelegramInlineButtons } from "./button-types.js";
import { markdownToTelegramHtmlChunks } from "./format.js";
import { parseTelegramReplyToMessageId, parseTelegramThreadId } from "./outbound-params.js";
import { sendMessageTelegram } from "./send.js";
@ -67,8 +67,10 @@ export async function sendTelegramPayloadMessages(params: {
interactive: params.payload.interactive,
}) ?? "";
const mediaUrls = resolvePayloadMediaUrls(params.payload);
const interactiveButtons = buildTelegramInteractiveButtons(params.payload.interactive);
const buttons = telegramData?.buttons ?? interactiveButtons;
const buttons = resolveTelegramInlineButtons({
buttons: telegramData?.buttons,
interactive: params.payload.interactive,
});
const payloadOpts = {
...params.baseOpts,
quoteText,

View File

@ -318,6 +318,7 @@
"test:docker:plugins": "bash scripts/e2e/plugins-docker.sh",
"test:docker:qr": "bash scripts/e2e/qr-import-docker.sh",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:e2e:openshell": "OPENCLAW_E2E_OPENSHELL=1 vitest run --config vitest.e2e.config.ts test/openshell-sandbox.e2e.test.ts",
"test:extensions": "vitest run --config vitest.extensions.config.ts",
"test:fast": "vitest run --config vitest.unit.config.ts",
"test:force": "node --import tsx scripts/test-force.ts",

View File

@ -371,9 +371,10 @@ phase_run() {
local timeout_s="$2"
shift 2
local log_path pid rc timed_out
local log_path pid start rc timed_out
log_path="$(phase_log_path "$phase_id")"
say "$phase_id"
start=$SECONDS
timed_out=0
(
@ -381,26 +382,22 @@ phase_run() {
) >"$log_path" 2>&1 &
pid=$!
(
sleep "$timeout_s"
kill "$pid" >/dev/null 2>&1 || true
sleep 2
kill -9 "$pid" >/dev/null 2>&1 || true
) &
local killer_pid=$!
while kill -0 "$pid" >/dev/null 2>&1; do
if (( SECONDS - start >= timeout_s )); then
timed_out=1
kill "$pid" >/dev/null 2>&1 || true
sleep 2
kill -9 "$pid" >/dev/null 2>&1 || true
break
fi
sleep 1
done
set +e
wait "$pid"
rc=$?
set -e
if kill -0 "$killer_pid" >/dev/null 2>&1; then
kill "$killer_pid" >/dev/null 2>&1 || true
wait "$killer_pid" >/dev/null 2>&1 || true
else
timed_out=1
fi
if (( timed_out )); then
warn "$phase_id timed out after ${timeout_s}s"
printf 'timeout after %ss\n' "$timeout_s" >>"$log_path"
@ -770,7 +767,7 @@ show_gateway_status_compat() {
}
verify_turn() {
guest_run_openclaw "" "" agent --agent main --message ping --json
guest_run_openclaw "" "" agent --agent main --message "Reply with exact ASCII text OK only." --json
}
capture_latest_ref_failure() {

View File

@ -7,7 +7,6 @@ import { normalizeAgentId } from "../routing/session-key.js";
import { resolveThreadParentSessionKey } from "../sessions/session-key-utils.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
import { resolveAgentConfig, resolveAgentIdFromSessionKey } from "./agent-scope.js";
import { compileGlobPatterns, matchesAnyGlobPattern } from "./glob-pattern.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
import { pickSandboxToolPolicy } from "./sandbox-tool-policy.js";
import type { SandboxToolPolicy } from "./sandbox.js";
@ -15,34 +14,8 @@ import {
resolveStoredSubagentCapabilities,
type SubagentSessionRole,
} from "./subagent-capabilities.js";
import { expandToolGroups, normalizeToolName } from "./tool-policy.js";
function makeToolPolicyMatcher(policy: SandboxToolPolicy) {
const deny = compileGlobPatterns({
raw: expandToolGroups(policy.deny ?? []),
normalize: normalizeToolName,
});
const allow = compileGlobPatterns({
raw: expandToolGroups(policy.allow ?? []),
normalize: normalizeToolName,
});
return (name: string) => {
const normalized = normalizeToolName(name);
if (matchesAnyGlobPattern(normalized, deny)) {
return false;
}
if (allow.length === 0) {
return true;
}
if (matchesAnyGlobPattern(normalized, allow)) {
return true;
}
if (normalized === "apply_patch" && matchesAnyGlobPattern("exec", allow)) {
return true;
}
return false;
};
}
import { isToolAllowedByPolicies, isToolAllowedByPolicyName } from "./tool-policy-match.js";
import { normalizeToolName } from "./tool-policy.js";
/**
* Tools always denied for sub-agents regardless of depth.
@ -140,19 +113,11 @@ export function resolveSubagentToolPolicyForSession(
return { allow: mergedAllow, deny };
}
export function isToolAllowedByPolicyName(name: string, policy?: SandboxToolPolicy): boolean {
if (!policy) {
return true;
}
return makeToolPolicyMatcher(policy)(name);
}
export function filterToolsByPolicy(tools: AnyAgentTool[], policy?: SandboxToolPolicy) {
if (!policy) {
return tools;
}
const matcher = makeToolPolicyMatcher(policy);
return tools.filter((tool) => matcher(tool.name));
return tools.filter((tool) => isToolAllowedByPolicyName(tool.name, policy));
}
type ToolPolicyConfig = {
@ -381,9 +346,4 @@ export function resolveGroupToolPolicy(params: {
return pickSandboxToolPolicy(toolsConfig);
}
export function isToolAllowedByPolicies(
name: string,
policies: Array<SandboxToolPolicy | undefined>,
) {
return policies.every((policy) => isToolAllowedByPolicyName(name, policy));
}
export { isToolAllowedByPolicies, isToolAllowedByPolicyName } from "./tool-policy-match.js";

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