Merge remote-tracking branch 'upstream/main' into fix-fake-ip-ssrf-guard
This commit is contained in:
commit
7d43cdea95
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -55,10 +55,6 @@
|
||||
"source": "CLI Setup Reference",
|
||||
"target": "CLI 设置参考"
|
||||
},
|
||||
{
|
||||
"source": "Setup Overview",
|
||||
"target": "设置概览"
|
||||
},
|
||||
{
|
||||
"source": "Setup Wizard (CLI)",
|
||||
"target": "设置向导(CLI)"
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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`
|
||||
- You’re 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
|
||||
```
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 pi‑ai 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 pi‑ai 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 pi‑ai 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 pi‑ai 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 pi‑ai 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 pi‑ai 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 pi‑ai 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
|
||||
```
|
||||
|
||||
@ -39,7 +39,7 @@ Related:
|
||||
If you don’t 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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -50,7 +50,7 @@ openclaw doctor
|
||||
```
|
||||
|
||||
If you’d 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).
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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`) |
|
||||
|
||||
@ -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.).
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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" \
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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/...`).
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
```
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
- Non‑loopback 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 \
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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** (auto‑generated, 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)
|
||||
|
||||
@ -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 plugin’s own provider id
|
||||
or `{ providers }` for multi-provider discovery.
|
||||
- `discovery.order` controls when the provider runs relative to built-in
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
66
extensions/discord/src/shared-interactive.ts
Normal file
66
extensions/discord/src/shared-interactive.ts
Normal 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;
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
129
extensions/feishu/src/card-interaction.test.ts
Normal file
129
extensions/feishu/src/card-interaction.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
168
extensions/feishu/src/card-interaction.ts
Normal file
168
extensions/feishu/src/card-interaction.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
65
extensions/feishu/src/card-ux-approval.ts
Normal file
65
extensions/feishu/src/card-ux-approval.ts
Normal 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,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
98
extensions/feishu/src/card-ux-launcher.test.ts
Normal file
98
extensions/feishu/src/card-ux-launcher.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
120
extensions/feishu/src/card-ux-launcher.ts
Normal file
120
extensions/feishu/src/card-ux-launcher.ts
Normal 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;
|
||||
}
|
||||
33
extensions/feishu/src/card-ux-shared.ts
Normal file
33
extensions/feishu/src/card-ux-shared.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
@ -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) => {
|
||||
|
||||
229
extensions/feishu/src/monitor.bot-menu.test.ts
Normal file
229
extensions/feishu/src/monitor.bot-menu.test.ts
Normal 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"}',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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),
|
||||
|
||||
@ -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"),
|
||||
},
|
||||
],
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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") {
|
||||
|
||||
69
extensions/telegram/src/button-types.test.ts
Normal file
69
extensions/telegram/src/button-types.test.ts
Normal 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" }]]);
|
||||
});
|
||||
});
|
||||
@ -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))
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 ??
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user