Merge branch 'main' into fix/token-usage-input-output-breakdown
This commit is contained in:
commit
ef90401a49
80
.github/labeler.yml
vendored
80
.github/labeler.yml
vendored
@ -198,14 +198,6 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/diagnostics-otel/**"
|
||||
"extensions: google-antigravity-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/google-antigravity-auth/**"
|
||||
"extensions: google-gemini-cli-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/google-gemini-cli-auth/**"
|
||||
"extensions: llm-task":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
@ -238,15 +230,87 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/acpx/**"
|
||||
"extensions: byteplus":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/byteplus/**"
|
||||
"extensions: anthropic":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/anthropic/**"
|
||||
"extensions: cloudflare-ai-gateway":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/cloudflare-ai-gateway/**"
|
||||
"extensions: minimax-portal-auth":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/minimax-portal-auth/**"
|
||||
"extensions: huggingface":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/huggingface/**"
|
||||
"extensions: kilocode":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/kilocode/**"
|
||||
"extensions: openai":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/openai/**"
|
||||
"extensions: kimi-coding":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/kimi-coding/**"
|
||||
"extensions: minimax":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/minimax/**"
|
||||
"extensions: modelstudio":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/modelstudio/**"
|
||||
"extensions: moonshot":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/moonshot/**"
|
||||
"extensions: nvidia":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/nvidia/**"
|
||||
"extensions: phone-control":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/phone-control/**"
|
||||
"extensions: qianfan":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/qianfan/**"
|
||||
"extensions: synthetic":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/synthetic/**"
|
||||
"extensions: talk-voice":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/talk-voice/**"
|
||||
"extensions: together":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/together/**"
|
||||
"extensions: venice":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/venice/**"
|
||||
"extensions: vercel-ai-gateway":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/vercel-ai-gateway/**"
|
||||
"extensions: volcengine":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/volcengine/**"
|
||||
"extensions: xiaomi":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/xiaomi/**"
|
||||
|
||||
23
.github/workflows/ci.yml
vendored
23
.github/workflows/ci.yml
vendored
@ -232,6 +232,29 @@ jobs:
|
||||
- name: Enforce safe external URL opening policy
|
||||
run: pnpm lint:ui:no-raw-window-open
|
||||
|
||||
startup-memory:
|
||||
name: "startup-memory"
|
||||
needs: [docs-scope, changed-scope]
|
||||
if: needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_node == 'true'
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
use-sticky-disk: "false"
|
||||
|
||||
- name: Build dist
|
||||
run: pnpm build
|
||||
|
||||
- name: Check CLI startup memory
|
||||
run: pnpm test:startup:memory
|
||||
|
||||
# Validate docs (format, lint, broken links) only when docs files changed.
|
||||
check-docs:
|
||||
needs: [docs-scope]
|
||||
|
||||
1
.prettierignore
Normal file
1
.prettierignore
Normal file
@ -0,0 +1 @@
|
||||
docs/.generated/
|
||||
44
AGENTS.md
44
AGENTS.md
@ -72,6 +72,8 @@
|
||||
|
||||
- `docs/zh-CN/**` is generated; do not edit unless the user explicitly asks.
|
||||
- Pipeline: update English docs → adjust glossary (`docs/.i18n/glossary.zh-CN.json`) → run `scripts/docs-i18n` → apply targeted fixes only if instructed.
|
||||
- Before rerunning `scripts/docs-i18n`, add glossary entries for any new technical terms, page titles, or short nav labels that must stay in English or use a fixed translation (for example `Doctor` or `Polls`).
|
||||
- `pnpm docs:check-i18n-glossary` enforces glossary coverage for changed English doc titles and short internal doc labels before translation reruns.
|
||||
- Translation memory: `docs/.i18n/zh-CN.tm.jsonl` (generated).
|
||||
- See `docs/.i18n/README.md`.
|
||||
- The pipeline can be slow/inefficient; if it’s dragging, ping @jospalmbier on Discord instead of hacking around it.
|
||||
@ -97,7 +99,7 @@
|
||||
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
|
||||
- Run CLI in dev: `pnpm openclaw ...` (bun) or `pnpm dev`.
|
||||
- Node remains supported for running built output (`dist/*`) and production installs.
|
||||
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`.
|
||||
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch.
|
||||
- Type-check/build: `pnpm build`
|
||||
- TypeScript checks: `pnpm tsgo`
|
||||
- Lint/format: `pnpm check`
|
||||
@ -179,7 +181,7 @@
|
||||
- Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable.
|
||||
- Environment variables: see `~/.profile`.
|
||||
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
|
||||
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
|
||||
- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook; use `docs/reference/RELEASING.md` for the public release policy.
|
||||
|
||||
## GHSA (Repo Advisory) Patch/Publish
|
||||
|
||||
@ -256,14 +258,13 @@
|
||||
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
|
||||
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
|
||||
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
|
||||
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), `docs/platforms/mac/release.md` (APP_VERSION/APP_BUILD examples), Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
|
||||
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), and Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
|
||||
- "Bump version everywhere" means all version locations above **except** `appcast.xml` (only touch appcast when cutting a new macOS Sparkle release).
|
||||
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
|
||||
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
|
||||
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
|
||||
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
|
||||
- Release signing/notary keys are managed outside the repo; follow internal release docs.
|
||||
- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are expected in your environment (per internal release docs).
|
||||
- Release signing/notary credentials are managed outside the repo; maintainers keep that setup in the private [maintainer release docs](https://github.com/openclaw/maintainers/tree/main/release).
|
||||
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
|
||||
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
|
||||
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
|
||||
@ -290,35 +291,12 @@
|
||||
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
|
||||
- Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked.
|
||||
|
||||
## NPM + 1Password (publish/verify)
|
||||
## Release Auth
|
||||
|
||||
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
|
||||
- Correct 1Password path for npm release auth: `op://Private/Npmjs` (use that item; OTP stays `op://Private/Npmjs/one-time password?attribute=otp`).
|
||||
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
|
||||
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
|
||||
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
||||
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
||||
- Kill the tmux session after publish.
|
||||
|
||||
## Plugin Release Fast Path (no core `openclaw` publish)
|
||||
|
||||
- Release only already-on-npm plugins. Source list is in `docs/reference/RELEASING.md` under "Current npm plugin list".
|
||||
- Run all CLI `op` calls and `npm publish` inside tmux to avoid hangs/interruption:
|
||||
- `tmux new -d -s release-plugins-$(date +%Y%m%d-%H%M%S)`
|
||||
- `eval "$(op signin --account my.1password.com)"`
|
||||
- 1Password helpers:
|
||||
- password used by `npm login`:
|
||||
`op item get Npmjs --format=json | jq -r '.fields[] | select(.id=="password").value'`
|
||||
- OTP:
|
||||
`op read 'op://Private/Npmjs/one-time password?attribute=otp'`
|
||||
- Fast publish loop (local helper script in `/tmp` is fine; keep repo clean):
|
||||
- compare local plugin `version` to `npm view <name> version`
|
||||
- only run `npm publish --access public --otp="<otp>"` when versions differ
|
||||
- skip if package is missing on npm or version already matches.
|
||||
- Keep `openclaw` untouched: never run publish from repo root unless explicitly requested.
|
||||
- Post-check for each release:
|
||||
- per-plugin: `npm view @openclaw/<name> version --userconfig "$(mktemp)"` should be `2026.2.17`
|
||||
- core guard: `npm view openclaw version --userconfig "$(mktemp)"` should stay at previous version unless explicitly requested.
|
||||
- Core `openclaw` publish uses GitHub trusted publishing; do not use `NPM_TOKEN` or the plugin OTP flow for core releases.
|
||||
- Separate `@openclaw/*` plugin publishes use a different maintainer-only auth flow.
|
||||
- Plugin scope: only publish already-on-npm `@openclaw/*` plugins. Bundled disk-tree-only plugins stay out.
|
||||
- Maintainers: private 1Password item names, tmux rules, plugin publish helpers, and local mac signing/notary setup live in the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md).
|
||||
|
||||
## Changelog Release Notes
|
||||
|
||||
|
||||
33
CHANGELOG.md
33
CHANGELOG.md
@ -10,16 +10,23 @@ Docs: https://docs.openclaw.ai
|
||||
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.
|
||||
- Gateway/health monitor: add configurable stale-event thresholds and restart limits, plus per-channel and per-account `healthMonitor.enabled` overrides, while keeping the existing global disable path on `gateway.channelHealthCheckMinutes=0`. (#42107) Thanks @rstar327.
|
||||
- Feishu/cards: add identity-aware structured card headers and note footers for Feishu replies and direct sends, while keeping that presentation wired through the shared outbound identity path. (#29938) Thanks @nszhsl.
|
||||
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior.
|
||||
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029)
|
||||
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
|
||||
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280.
|
||||
- Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898.
|
||||
- Install/update: allow package-manager installs from GitHub `main` via `openclaw update --tag main`, installer `--version main`, or direct npm/pnpm git specs.
|
||||
- Plugins/providers: move OpenRouter, GitHub Copilot, and OpenAI Codex provider/runtime logic into bundled plugins, including dynamic model fallback, runtime auth exchange, stream wrappers, capability hints, and cache-TTL policy.
|
||||
- Plugins/MiniMax: merge the bundled MiniMax API and MiniMax OAuth plugin surfaces into a single default-on `minimax` plugin, while keeping legacy `minimax-portal-auth` config ids aliased for compatibility.
|
||||
- Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized.
|
||||
- Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles.
|
||||
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) thanks @luzhidong.
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc.
|
||||
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. Thanks @Coobiw.
|
||||
- Control UI: scope persisted session selection per gateway, prevent stale session bleed across tokenized gateway opens, and cap stored gateway session history. (#47453) Thanks @sallyom.
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
|
||||
- Feishu/topic threads: fetch full thread context, including prior bot replies, when starting a topic-thread session so follow-up turns in Feishu topics keep the right conversation state. (#45254) Thanks @Coobiw.
|
||||
- Configure/startup: move outbound send-deps resolution into a lightweight helper so `openclaw configure` no longer stalls after the banner while eagerly loading channel plugins. (#46301) thanks @scoootscooob.
|
||||
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46532) Thanks @vincentkoc.
|
||||
- Android/chat: theme the thinking dropdown and TLS trust dialogs explicitly so popup surfaces match the active app theme instead of falling back to mismatched Material defaults.
|
||||
@ -29,15 +36,21 @@ Docs: https://docs.openclaw.ai
|
||||
- Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#40146)
|
||||
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
|
||||
- Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`.
|
||||
- ACP/acpx: resolve the bundled plugin root from the actual plugin directory so plugin-local installs stay under `dist/extensions/acpx` instead of escaping to `dist/extensions` and failing runtime setup.
|
||||
- Gateway/auth: ignore spoofed loopback hops in trusted forwarding chains and block device approvals that request scopes above the caller session. Thanks @vincentkoc.
|
||||
- Gateway/config views: strip embedded credentials from URL-based endpoint fields before returning read-only account and config snapshots. Thanks @vincentkoc.
|
||||
- Tools/apply-patch: revalidate workspace-only delete and directory targets immediately before mutating host paths. Thanks @vincentkoc.
|
||||
- Webhooks/runtime: move auth earlier and tighten pre-auth body limits and timeouts across bundled webhook handlers, including slow-body handling for Mattermost slash commands. Thanks @vincentkoc.
|
||||
- Subagents/follow-ups: require the same controller ownership checks for `/subagents send` as other control actions, so leaf sessions cannot message nested child runs they do not control. Thanks @vincentkoc.
|
||||
- Inbound policy hardening: tighten callback and webhook sender checks across Mattermost and Google Chat, match Nextcloud Talk rooms by stable room token, and treat explicit empty Twitch allowlists as deny-all. (#46787) Thanks @zpbrent, @ijxpwastaken and @vincentkoc.
|
||||
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. Thanks @vincentkoc.
|
||||
- macOS/canvas actions: keep unattended local agent actions on trusted in-app canvas surfaces only, and stop exposing the deep-link fallback key to arbitrary page scripts. (#46790) Thanks @vincentkoc.
|
||||
- Agents/compaction: extend the enclosing run deadline once while compaction is actively in flight, and abort the underlying SDK compaction on timeout/cancel so large-session compactions stop freezing mid-run. (#46889) Thanks @asyncjason.
|
||||
- Models/openai-completions: default non-native OpenAI-compatible providers to omit tool-definition `strict` fields unless users explicitly opt back in, so tool calling keeps working on providers that reject that option. (#45497) Thanks @sahancava.
|
||||
- WhatsApp/reconnect: restore the append recency filter in the extension inbox monitor and handle protobuf `Long` timestamps correctly, so fresh post-reconnect append messages are processed while stale history sync stays suppressed. (#42588) thanks @MonkeyLeeT.
|
||||
- WhatsApp/login: wait for pending creds writes before reopening after Baileys `515` pairing restarts in both QR login and `channels login` flows, and keep the restart coverage pinned to the real wrapped error shape plus per-account creds queues. (#27910) Thanks @asyncjason.
|
||||
- Agents/openai-compatible tool calls: deduplicate repeated tool call ids across live assistant messages and replayed history so OpenAI-compatible backends no longer reject duplicate `tool_call_id` values with HTTP 400. (#40996) Thanks @xaeon2026.
|
||||
- Security/device pairing: harden `device.token.rotate` deny handling by keeping public failures generic while logging internal deny reasons and preserving approved-baseline enforcement. (`GHSA-7jrw-x62h-64p8`)
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. Thanks @vincentkoc.
|
||||
- Slack/interactive replies: preserve `channelData.slack.blocks` through live DM delivery and preview-finalized edits so Block Kit button and select directives render instead of falling back to raw text. (#45890) Thanks @vincentkoc.
|
||||
- Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898.
|
||||
- CI/channel test routing: move the built-in channel suites into `test:channels` and keep them out of `test:extensions`, so extension CI no longer fails after the channel migration while targeted test routing still sends Slack, Signal, and iMessage suites to the right lane. (#46066) Thanks @scoootscooob.
|
||||
- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#45777) Thanks @odysseus0.
|
||||
@ -52,6 +65,15 @@ Docs: https://docs.openclaw.ai
|
||||
- CLI: avoid loading provider discovery during startup model normalization. (#46522) Thanks @ItsAditya-xyz and @vincentkoc.
|
||||
- Tlon: honor explicit empty allowlists and defer cite expansion. (#46788) Thanks @zpbrent and @vincentkoc.
|
||||
- ACP: require admin scope for mutating internal actions. (#46789) Thanks @tdjackey and @vincentkoc.
|
||||
- Gateway/config validation: stop treating the implicit default memory slot as a required explicit plugin config, so startup no longer fails with `plugins.slots.memory: plugin not found: memory-core` when `memory-core` was only inferred. (#47494) Thanks @ngutman.
|
||||
- CLI/startup: lazy-load channel add and root help startup paths to trim avoidable RSS and help latency on constrained hosts. (#46784) Thanks @vincentkoc.
|
||||
- CLI/onboarding: import static provider definitions directly for onboarding model/config helpers so those paths no longer pull provider discovery just for built-in defaults. (#47467) Thanks @vincentkoc.
|
||||
- CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc.
|
||||
- CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc.
|
||||
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse.
|
||||
- Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) thanks @gumadeiras.
|
||||
- Gateway/watch mode: recreate bundled plugin runtime metadata after clean or stale `dist` states, so `pnpm gateway:watch` no longer fails on missing `dist/extensions/*/openclaw.plugin.json` manifests after a rebuild. Thanks @gumadeiras.
|
||||
- Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc.
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
@ -87,6 +109,7 @@ Docs: https://docs.openclaw.ai
|
||||
- macOS/exec approvals: respect per-agent exec approval settings in the gateway prompter, including allowlist fallback when the native prompt cannot be shown, so gateway-triggered `system.run` requests follow configured policy instead of always prompting or denying unexpectedly. (#13707) Thanks @sliekens.
|
||||
- Telegram/media downloads: thread the same direct or proxy transport policy into SSRF-guarded file fetches so inbound attachments keep working when Telegram falls back between env-proxy and direct networking. (#44639) Thanks @obviyus.
|
||||
- Telegram/inbound media IPv4 fallback: retry SSRF-guarded Telegram file downloads once with the same IPv4 fallback policy as Bot API calls so fresh installs on IPv6-broken hosts no longer fail to download inbound images.
|
||||
- Commands/onboarding: split static auth-choice help from the plugin-backed onboarding catalog so `openclaw onboard` registration no longer pulls provider-wizard imports just to describe `--auth-choice`. (#47545) Thanks @vincentkoc.
|
||||
- Windows/gateway install: bound `schtasks` calls and fall back to the Startup-folder login item when task creation hangs, so native `openclaw gateway install` fails fast instead of wedging forever on broken Scheduled Task setups.
|
||||
- Windows/gateway stop: resolve Startup-folder fallback listeners from the installed `gateway.cmd` port, so `openclaw gateway stop` now actually kills fallback-launched gateway processes before restart.
|
||||
- Windows/gateway status: reuse the installed service command environment when reading runtime status, so startup-fallback gateways keep reporting the configured port and running state in `gateway status --json` instead of falling back to `gateway port unknown`.
|
||||
|
||||
@ -103,7 +103,7 @@ pnpm build
|
||||
|
||||
pnpm openclaw onboard --install-daemon
|
||||
|
||||
# Dev loop (auto-reload on TS changes)
|
||||
# Dev loop (auto-reload on source/config changes)
|
||||
pnpm gateway:watch
|
||||
```
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":4733}
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":4889}
|
||||
{"recordType":"path","path":"acp","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"ACP","help":"ACP runtime controls for enabling dispatch, selecting backends, constraining allowed agent targets, and tuning streamed turn projection behavior.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"ACP Allowed Agents","help":"Allowlist of ACP target agent ids permitted for ACP runtime sessions. Empty means no additional allowlist restriction.","hasChildren":true}
|
||||
{"recordType":"path","path":"acp.allowedAgents.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -101,6 +101,7 @@
|
||||
{"recordType":"path","path":"agents.defaults.compaction.recentTurnsPreserve","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Compaction Preserve Recent Turns","help":"Number of most recent user/assistant turns kept verbatim outside safeguard summarization (default: 3). Raise this to preserve exact recent dialogue context, or lower it to maximize compaction savings.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.compaction.reserveTokens","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["auth","security"],"label":"Compaction Reserve Tokens","help":"Token headroom reserved for reply generation and tool output after compaction runs. Use higher reserves for verbose/tool-heavy sessions, and lower reserves when maximizing retained history matters more.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.compaction.reserveTokensFloor","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["auth","security"],"label":"Compaction Reserve Token Floor","help":"Minimum floor enforced for reserveTokens in Pi compaction paths (0 disables the floor guard). Use a non-zero floor to avoid over-aggressive compression under fluctuating token estimates.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.compaction.timeoutSeconds","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Compaction Timeout (Seconds)","help":"Maximum time in seconds allowed for a single compaction operation before it is aborted (default: 900). Increase this for very large sessions that need more time to summarize, or decrease it to fail faster on unresponsive models.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.contextPruning","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"agents.defaults.contextPruning.hardClear","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"agents.defaults.contextPruning.hardClear.enabled","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -143,7 +144,7 @@
|
||||
{"recordType":"path","path":"agents.defaults.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.heartbeat.session","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.heartbeat.suppressToolErrorWarnings","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"label":"Heartbeat Suppress Tool Error Warnings","help":"Suppress tool error warning payloads during heartbeat runs.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.heartbeat.to","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.defaults.humanDelay","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"agents.defaults.humanDelay.maxMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Human Delay Max (ms)","help":"Maximum delay in ms for custom humanDelay (default: 2500).","hasChildren":false}
|
||||
@ -347,7 +348,7 @@
|
||||
{"recordType":"path","path":"agents.list.*.heartbeat.prompt","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.heartbeat.session","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.heartbeat.suppressToolErrorWarnings","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"label":"Agent Heartbeat Suppress Tool Error Warnings","help":"Suppress tool error warning payloads during heartbeat runs.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, zalouser, zalo, tlon, feishu, nextcloud-talk, msteams, bluebubbles, synology-chat, mattermost, twitch, matrix, nostr.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.heartbeat.target","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["automation"],"help":"Delivery target (\"last\", \"none\", or a channel id). Known channels: telegram, whatsapp, discord, irc, googlechat, slack, signal, imessage, line, bluebubbles, feishu, matrix, mattermost, msteams, nextcloud-talk, nostr, synology-chat, tlon, twitch, zalo, zalouser.","hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.heartbeat.to","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"agents.list.*.humanDelay","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"agents.list.*.humanDelay.maxMs","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -912,6 +913,8 @@
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.guilds.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -1165,6 +1168,8 @@
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.guilds.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.discord.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.discord.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -1280,61 +1285,182 @@
|
||||
{"recordType":"path","path":"channels.feishu","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Feishu","help":"飞书/Lark enterprise messaging.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.appId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.appSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.blockStreamingCoalesce.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.capabilities","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.capabilities.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.chunkMode","kind":"channel","type":"string","required":false,"enumValues":["length","newline"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.connectionMode","kind":"channel","type":"string","required":false,"enumValues":["websocket","webhook"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.dmHistoryLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.dmPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","pairing","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.dms","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.dms.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.dms.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.dms.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.domain","kind":"channel","type":"string","required":false,"enumValues":["feishu","lark"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.encryptKey.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","allowlist","disabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.allow","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.allow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.tools.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groups.*.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groupSenderAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groupSenderAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.heartbeat.intervalMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.heartbeat.visibility","kind":"channel","type":"string","required":false,"enumValues":["visible","hidden"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.httpTimeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.markdown","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.markdown.mode","kind":"channel","type":"string","required":false,"enumValues":["native","escape","strip"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.markdown.tableMode","kind":"channel","type":"string","required":false,"enumValues":["native","ascii","simple"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.reactionNotifications","kind":"channel","type":"string","required":false,"enumValues":["off","own","all"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.renderMode","kind":"channel","type":"string","required":false,"enumValues":["auto","raw","card"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.resolveSenderNames","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.streaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools.chat","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools.doc","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools.drive","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools.perm","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools.scopes","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.tools.wiki","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.typingIndicator","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.verificationToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.webhookHost","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.accounts.*.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.appId","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.appSecret","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.appSecret.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.appSecret.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.appSecret.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.appSecret.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.blockStreamingCoalesce.minDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.capabilities","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.capabilities.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.chunkMode","kind":"channel","type":"string","required":false,"enumValues":["length","newline"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.connectionMode","kind":"channel","type":"string","required":false,"enumValues":["websocket","webhook"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.connectionMode","kind":"channel","type":"string","required":true,"enumValues":["websocket","webhook"],"defaultValue":"websocket","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dmHistoryLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dmPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","pairing","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.domain","kind":"channel","type":"string","required":false,"enumValues":["feishu","lark"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dmPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","pairing","allowlist"],"defaultValue":"pairing","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dms","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.dms.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.dms.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dms.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.domain","kind":"channel","type":"string","required":true,"enumValues":["feishu","lark"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.agentDirTemplate","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.maxAgents","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.dynamicAgentCreation.workspaceTemplate","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.encryptKey","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.encryptKey.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.encryptKey.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.encryptKey.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.encryptKey.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","allowlist","disabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","allowlist","disabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.allowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.allowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.tools.allow","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.tools.allow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.tools.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.tools.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groups.*.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groupSenderAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.groupSenderAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.groupSessionScope","kind":"channel","type":"string","required":false,"enumValues":["group","group_sender","group_topic","group_topic_sender"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.heartbeat.intervalMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.heartbeat.visibility","kind":"channel","type":"string","required":false,"enumValues":["visible","hidden"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.httpTimeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.markdown","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.markdown.mode","kind":"channel","type":"string","required":false,"enumValues":["native","escape","strip"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.markdown.tableMode","kind":"channel","type":"string","required":false,"enumValues":["native","ascii","simple"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.reactionNotifications","kind":"channel","type":"string","required":true,"enumValues":["off","own","all"],"defaultValue":"own","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.renderMode","kind":"channel","type":"string","required":false,"enumValues":["auto","raw","card"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.replyInThread","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.requireMention","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.requireMention","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.resolveSenderNames","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.streaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.textChunkLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.tools","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.tools.chat","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.tools.doc","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.tools.drive","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.tools.perm","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.tools.scopes","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.tools.wiki","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.topicSessionMode","kind":"channel","type":"string","required":false,"enumValues":["disabled","enabled"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.typingIndicator","kind":"channel","type":"boolean","required":true,"defaultValue":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.verificationToken","kind":"channel","type":["object","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.feishu.verificationToken.id","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.verificationToken.provider","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.verificationToken.source","kind":"channel","type":"string","required":true,"enumValues":["env","file","exec"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.verificationToken.source","kind":"channel","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.webhookHost","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.webhookPath","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.webhookPath","kind":"channel","type":"string","required":true,"defaultValue":"/feishu/events","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.feishu.webhookPort","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Google Chat","help":"Google Workspace Chat app with HTTP webhook.","hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@ -1342,6 +1468,7 @@
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.allowBots","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.appPrincipal","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.audience","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.audienceType","kind":"channel","type":"string","required":false,"enumValues":["app-url","project-number"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.blockStreaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -1377,6 +1504,8 @@
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.groups.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.groups.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.accounts.*.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -1401,6 +1530,7 @@
|
||||
{"recordType":"path","path":"channels.googlechat.actions","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.actions.reactions","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.allowBots","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.appPrincipal","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.audience","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.audienceType","kind":"channel","type":"string","required":false,"enumValues":["app-url","project-number"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.blockStreaming","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -1437,6 +1567,8 @@
|
||||
{"recordType":"path","path":"channels.googlechat.groups.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.groups.*.users","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.groups.*.users.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.googlechat.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.mediaMaxMb","kind":"channel","type":"number","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.googlechat.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -1504,6 +1636,8 @@
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -1565,6 +1699,8 @@
|
||||
{"recordType":"path","path":"channels.imessage.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.imessage.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.imessage.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -1969,6 +2105,8 @@
|
||||
{"recordType":"path","path":"channels.msteams.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.msteams.groupAllowFrom.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.msteams.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.msteams.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.msteams.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.msteams.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.msteams.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.msteams.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -2214,6 +2352,8 @@
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -2282,6 +2422,8 @@
|
||||
{"recordType":"path","path":"channels.signal.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.signal.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.signal.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -2386,6 +2528,8 @@
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.dms.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -2509,6 +2653,8 @@
|
||||
{"recordType":"path","path":"channels.slack.dms.*.historyLimit","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.slack.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.slack.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -2688,6 +2834,8 @@
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.groups.*.topics.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.groups.*.topics.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.groups.*.topics.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -2862,6 +3010,8 @@
|
||||
{"recordType":"path","path":"channels.telegram.groups.*.topics.*.skills","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.groups.*.topics.*.skills.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.groups.*.topics.*.systemPrompt","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.telegram.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.telegram.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -3032,6 +3182,8 @@
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -3095,6 +3247,8 @@
|
||||
{"recordType":"path","path":"channels.whatsapp.groups.*.toolsBySender.*.alsoAllow.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.groups.*.toolsBySender.*.deny","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.groups.*.toolsBySender.*.deny.*","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.healthMonitor","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.healthMonitor.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.heartbeat","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.whatsapp.heartbeat.showAlerts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.heartbeat.showOk","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -3330,6 +3484,8 @@
|
||||
{"recordType":"path","path":"gateway.auth.trustedProxy.userHeader","kind":"core","type":"string","required":true,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.bind","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Bind Mode","help":"Network bind profile: \"auto\", \"lan\", \"loopback\", \"custom\", or \"tailnet\" to control interface exposure. Keep \"loopback\" or \"auto\" for safest local operation unless external clients must connect.","hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.channelHealthCheckMinutes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network","reliability"],"label":"Gateway Channel Health Check Interval (min)","help":"Interval in minutes for automatic channel health probing and status updates. Use lower intervals for faster detection, or higher intervals to reduce periodic probe noise.","hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.channelMaxRestartsPerHour","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network","performance"],"label":"Gateway Channel Max Restarts Per Hour","help":"Maximum number of health-monitor-initiated channel restarts allowed within a rolling one-hour window. Once hit, further restarts are skipped until the window expires. Default: 10.","hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.channelStaleEventThresholdMinutes","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Channel Stale Event Threshold (min)","help":"How many minutes a connected channel can go without receiving any event before the health monitor treats it as a stale socket and triggers a restart. Default: 30.","hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.controlUi","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Control UI","help":"Control UI hosting settings including enablement, pathing, and browser-origin/auth hardening behavior. Keep UI exposure minimal and pair with strong auth controls before internet-facing deployments.","hasChildren":true}
|
||||
{"recordType":"path","path":"gateway.controlUi.allowedOrigins","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","network"],"label":"Control UI Allowed Origins","help":"Allowed browser origins for Control UI/WebChat websocket connections (full origins only, e.g. https://control.example.com). Required for non-loopback Control UI deployments unless dangerous Host-header fallback is explicitly enabled.","hasChildren":true}
|
||||
{"recordType":"path","path":"gateway.controlUi.allowedOrigins.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@ -3584,7 +3740,7 @@
|
||||
{"recordType":"path","path":"messages.ackReactionScope","kind":"core","type":"string","required":false,"enumValues":["group-mentions","group-all","direct","all","off","none"],"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Ack Reaction Scope","help":"When to send ack reactions (\"group-mentions\", \"group-all\", \"direct\", \"all\", \"off\", \"none\"). \"off\"/\"none\" disables ack reactions entirely.","hasChildren":false}
|
||||
{"recordType":"path","path":"messages.groupChat","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Group Chat Rules","help":"Group-message handling controls including mention triggers and history window sizing. Keep mention patterns narrow so group channels do not trigger on every message.","hasChildren":true}
|
||||
{"recordType":"path","path":"messages.groupChat.historyLimit","kind":"core","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":["performance"],"label":"Group History Limit","help":"Maximum number of prior group messages loaded as context per turn for group sessions. Use higher values for richer continuity, or lower values for faster and cheaper responses.","hasChildren":false}
|
||||
{"recordType":"path","path":"messages.groupChat.mentionPatterns","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Group Mention Patterns","help":"Regex-like patterns used to detect explicit mentions/trigger phrases in group chats. Use precise patterns to reduce false positives in high-volume channels.","hasChildren":true}
|
||||
{"recordType":"path","path":"messages.groupChat.mentionPatterns","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Group Mention Patterns","help":"Safe case-insensitive regex patterns used to detect explicit mentions/trigger phrases in group chats. Use precise patterns to reduce false positives in high-volume channels; invalid or unsafe nested-repetition patterns are ignored.","hasChildren":true}
|
||||
{"recordType":"path","path":"messages.groupChat.mentionPatterns.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"messages.inbound","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Inbound Debounce","help":"Direct inbound debounce settings used before queue/turn processing starts. Configure this for provider-specific rapid message bursts from the same sender.","hasChildren":true}
|
||||
{"recordType":"path","path":"messages.inbound.byChannel","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Inbound Debounce by Channel (ms)","help":"Per-channel inbound debounce overrides keyed by provider id in milliseconds. Use this where some providers send message fragments more aggressively than others.","hasChildren":true}
|
||||
|
||||
@ -123,6 +123,22 @@
|
||||
"source": "Network model",
|
||||
"target": "网络模型"
|
||||
},
|
||||
{
|
||||
"source": "Doctor",
|
||||
"target": "Doctor"
|
||||
},
|
||||
{
|
||||
"source": "Polls",
|
||||
"target": "投票"
|
||||
},
|
||||
{
|
||||
"source": "Release Policy",
|
||||
"target": "发布策略"
|
||||
},
|
||||
{
|
||||
"source": "Release policy",
|
||||
"target": "发布策略"
|
||||
},
|
||||
{
|
||||
"source": "for full details",
|
||||
"target": "了解详情"
|
||||
|
||||
@ -7,7 +7,7 @@ title: "Zalo"
|
||||
|
||||
# Zalo (Bot API)
|
||||
|
||||
Status: experimental. DMs are supported; group handling is available with explicit group policy controls.
|
||||
Status: experimental. DMs are supported. The [Capabilities](#capabilities) section below reflects current Marketplace-bot behavior.
|
||||
|
||||
## Plugin required
|
||||
|
||||
@ -25,7 +25,7 @@ Zalo ships as a plugin and is not bundled with the core install.
|
||||
- Or pick **Zalo** in onboarding and confirm the install prompt
|
||||
2. Set the token:
|
||||
- Env: `ZALO_BOT_TOKEN=...`
|
||||
- Or config: `channels.zalo.botToken: "..."`.
|
||||
- Or config: `channels.zalo.accounts.default.botToken: "..."`.
|
||||
3. Restart the gateway (or finish onboarding).
|
||||
4. DM access is pairing by default; approve the pairing code on first contact.
|
||||
|
||||
@ -36,8 +36,12 @@ Minimal config:
|
||||
channels: {
|
||||
zalo: {
|
||||
enabled: true,
|
||||
botToken: "12345689:abc-xyz",
|
||||
dmPolicy: "pairing",
|
||||
accounts: {
|
||||
default: {
|
||||
botToken: "12345689:abc-xyz",
|
||||
dmPolicy: "pairing",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -48,10 +52,13 @@ Minimal config:
|
||||
Zalo is a Vietnam-focused messaging app; its Bot API lets the Gateway run a bot for 1:1 conversations.
|
||||
It is a good fit for support or notifications where you want deterministic routing back to Zalo.
|
||||
|
||||
This page reflects current OpenClaw behavior for **Zalo Bot Creator / Marketplace bots**.
|
||||
**Zalo Official Account (OA) bots** are a different Zalo product surface and may behave differently.
|
||||
|
||||
- A Zalo Bot API channel owned by the Gateway.
|
||||
- Deterministic routing: replies go back to Zalo; the model never chooses channels.
|
||||
- DMs share the agent's main session.
|
||||
- Groups are supported with policy controls (`groupPolicy` + `groupAllowFrom`) and default to fail-closed allowlist behavior.
|
||||
- The [Capabilities](#capabilities) section below shows current Marketplace-bot support.
|
||||
|
||||
## Setup (fast path)
|
||||
|
||||
@ -59,7 +66,7 @@ It is a good fit for support or notifications where you want deterministic routi
|
||||
|
||||
1. Go to [https://bot.zaloplatforms.com](https://bot.zaloplatforms.com) and sign in.
|
||||
2. Create a new bot and configure its settings.
|
||||
3. Copy the bot token (format: `12345689:abc-xyz`).
|
||||
3. Copy the full bot token (typically `numeric_id:secret`). For Marketplace bots, the usable runtime token may appear in the bot's welcome message after creation.
|
||||
|
||||
### 2) Configure the token (env or config)
|
||||
|
||||
@ -70,13 +77,19 @@ Example:
|
||||
channels: {
|
||||
zalo: {
|
||||
enabled: true,
|
||||
botToken: "12345689:abc-xyz",
|
||||
dmPolicy: "pairing",
|
||||
accounts: {
|
||||
default: {
|
||||
botToken: "12345689:abc-xyz",
|
||||
dmPolicy: "pairing",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
If you later move to a Zalo bot surface where groups are available, you can add group-specific config such as `groupPolicy` and `groupAllowFrom` explicitly. For current Marketplace-bot behavior, see [Capabilities](#capabilities).
|
||||
|
||||
Env option: `ZALO_BOT_TOKEN=...` (works for the default account only).
|
||||
|
||||
Multi-account support: use `channels.zalo.accounts` with per-account tokens and optional `name`.
|
||||
@ -109,14 +122,23 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
|
||||
|
||||
## Access control (Groups)
|
||||
|
||||
For **Zalo Bot Creator / Marketplace bots**, group support was not available in practice because the bot could not be added to a group at all.
|
||||
|
||||
That means the group-related config keys below exist in the schema, but were not usable for Marketplace bots:
|
||||
|
||||
- `channels.zalo.groupPolicy` controls group inbound handling: `open | allowlist | disabled`.
|
||||
- Default behavior is fail-closed: `allowlist`.
|
||||
- `channels.zalo.groupAllowFrom` restricts which sender IDs can trigger the bot in groups.
|
||||
- If `groupAllowFrom` is unset, Zalo falls back to `allowFrom` for sender checks.
|
||||
- `groupPolicy: "disabled"` blocks all group messages.
|
||||
- `groupPolicy: "open"` allows any group member (mention-gated).
|
||||
- Runtime note: if `channels.zalo` is missing entirely, runtime still falls back to `groupPolicy="allowlist"` for safety.
|
||||
|
||||
The group policy values (when group access is available on your bot surface) are:
|
||||
|
||||
- `groupPolicy: "disabled"` — blocks all group messages.
|
||||
- `groupPolicy: "open"` — allows any group member (mention-gated).
|
||||
- `groupPolicy: "allowlist"` — fail-closed default; only allowed senders are accepted.
|
||||
|
||||
If you are using a different Zalo bot product surface and have verified working group behavior, document that separately rather than assuming it matches the Marketplace-bot flow.
|
||||
|
||||
## Long-polling vs webhook
|
||||
|
||||
- Default: long-polling (no public URL required).
|
||||
@ -133,23 +155,36 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
|
||||
|
||||
## Supported message types
|
||||
|
||||
For a quick support snapshot, see [Capabilities](#capabilities). The notes below add detail where the behavior needs extra context.
|
||||
|
||||
- **Text messages**: Full support with 2000 character chunking.
|
||||
- **Image messages**: Download and process inbound images; send images via `sendPhoto`.
|
||||
- **Stickers**: Logged but not fully processed (no agent response).
|
||||
- **Unsupported types**: Logged (e.g., messages from protected users).
|
||||
- **Plain URLs in text**: Behave like normal text input.
|
||||
- **Link previews / rich link cards**: See the Marketplace-bot status in [Capabilities](#capabilities); they did not reliably trigger a reply.
|
||||
- **Image messages**: See the Marketplace-bot status in [Capabilities](#capabilities); inbound image handling was unreliable (typing indicator without a final reply).
|
||||
- **Stickers**: See the Marketplace-bot status in [Capabilities](#capabilities).
|
||||
- **Voice notes / audio files / video / generic file attachments**: See the Marketplace-bot status in [Capabilities](#capabilities).
|
||||
- **Unsupported types**: Logged (for example, messages from protected users).
|
||||
|
||||
## Capabilities
|
||||
|
||||
| Feature | Status |
|
||||
| --------------- | -------------------------------------------------------- |
|
||||
| Direct messages | ✅ Supported |
|
||||
| Groups | ⚠️ Supported with policy controls (allowlist by default) |
|
||||
| Media (images) | ✅ Supported |
|
||||
| Reactions | ❌ Not supported |
|
||||
| Threads | ❌ Not supported |
|
||||
| Polls | ❌ Not supported |
|
||||
| Native commands | ❌ Not supported |
|
||||
| Streaming | ⚠️ Blocked (2000 char limit) |
|
||||
This table summarizes current **Zalo Bot Creator / Marketplace bot** behavior in OpenClaw.
|
||||
|
||||
| Feature | Status |
|
||||
| --------------------------- | --------------------------------------- |
|
||||
| Direct messages | ✅ Supported |
|
||||
| Groups | ❌ Not available for Marketplace bots |
|
||||
| Media (inbound images) | ⚠️ Limited / verify in your environment |
|
||||
| Media (outbound images) | ⚠️ Not re-tested for Marketplace bots |
|
||||
| Plain URLs in text | ✅ Supported |
|
||||
| Link previews | ⚠️ Unreliable for Marketplace bots |
|
||||
| Reactions | ❌ Not supported |
|
||||
| Stickers | ⚠️ No agent reply for Marketplace bots |
|
||||
| Voice notes / audio / video | ⚠️ No agent reply for Marketplace bots |
|
||||
| File attachments | ⚠️ No agent reply for Marketplace bots |
|
||||
| Threads | ❌ Not supported |
|
||||
| Polls | ❌ Not supported |
|
||||
| Native commands | ❌ Not supported |
|
||||
| Streaming | ⚠️ Blocked (2000 char limit) |
|
||||
|
||||
## Delivery targets (CLI/cron)
|
||||
|
||||
@ -175,6 +210,8 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
|
||||
|
||||
Full configuration: [Configuration](/gateway/configuration)
|
||||
|
||||
The flat top-level keys (`channels.zalo.botToken`, `channels.zalo.dmPolicy`, and similar) are a legacy single-account shorthand. Prefer `channels.zalo.accounts.<id>.*` for new configs. Both forms are still documented here because they exist in the schema.
|
||||
|
||||
Provider options:
|
||||
|
||||
- `channels.zalo.enabled`: enable/disable channel startup.
|
||||
@ -182,7 +219,7 @@ Provider options:
|
||||
- `channels.zalo.tokenFile`: read token from a regular file path. Symlinks are rejected.
|
||||
- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs.
|
||||
- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||
- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist). Present in config; see [Capabilities](#capabilities) and [Access control (Groups)](#access-control-groups) for current Marketplace-bot behavior.
|
||||
- `channels.zalo.groupAllowFrom`: group sender allowlist (user IDs). Falls back to `allowFrom` when unset.
|
||||
- `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5).
|
||||
- `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required).
|
||||
@ -198,7 +235,7 @@ Multi-account options:
|
||||
- `channels.zalo.accounts.<id>.enabled`: enable/disable account.
|
||||
- `channels.zalo.accounts.<id>.dmPolicy`: per-account DM policy.
|
||||
- `channels.zalo.accounts.<id>.allowFrom`: per-account allowlist.
|
||||
- `channels.zalo.accounts.<id>.groupPolicy`: per-account group policy.
|
||||
- `channels.zalo.accounts.<id>.groupPolicy`: per-account group policy. Present in config; see [Capabilities](#capabilities) and [Access control (Groups)](#access-control-groups) for current Marketplace-bot behavior.
|
||||
- `channels.zalo.accounts.<id>.groupAllowFrom`: per-account group sender allowlist.
|
||||
- `channels.zalo.accounts.<id>.webhookUrl`: per-account webhook URL.
|
||||
- `channels.zalo.accounts.<id>.webhookSecret`: per-account webhook secret.
|
||||
|
||||
@ -676,7 +676,7 @@ Surfaces:
|
||||
Notes:
|
||||
|
||||
- Data comes directly from provider usage endpoints (no estimates).
|
||||
- Providers: Anthropic, GitHub Copilot, OpenAI Codex OAuth, plus Gemini CLI/Antigravity when those provider plugins are enabled.
|
||||
- Providers: Anthropic, GitHub Copilot, OpenAI Codex OAuth, plus Gemini CLI via the bundled `google` plugin and Antigravity where configured.
|
||||
- If no matching credentials exist, usage is hidden.
|
||||
- Details: see [Usage tracking](/concepts/usage-tracking).
|
||||
|
||||
|
||||
@ -1,18 +1,19 @@
|
||||
---
|
||||
summary: "CLI reference for `openclaw plugins` (list, install, uninstall, enable/disable, doctor)"
|
||||
read_when:
|
||||
- You want to install or manage in-process Gateway plugins
|
||||
- You want to install or manage Gateway plugins or compatible bundles
|
||||
- You want to debug plugin load failures
|
||||
title: "plugins"
|
||||
---
|
||||
|
||||
# `openclaw plugins`
|
||||
|
||||
Manage Gateway plugins/extensions (loaded in-process).
|
||||
Manage Gateway plugins/extensions and compatible bundles.
|
||||
|
||||
Related:
|
||||
|
||||
- Plugin system: [Plugins](/tools/plugin)
|
||||
- Bundle compatibility: [Plugin bundles](/plugins/bundles)
|
||||
- Plugin manifest + schema: [Plugin manifest](/plugins/manifest)
|
||||
- Security hardening: [Security](/gateway/security)
|
||||
|
||||
@ -32,9 +33,13 @@ openclaw plugins update --all
|
||||
Bundled plugins ship with OpenClaw but start disabled. Use `plugins enable` to
|
||||
activate them.
|
||||
|
||||
All plugins must ship a `openclaw.plugin.json` file with an inline JSON Schema
|
||||
(`configSchema`, even if empty). Missing/invalid manifests or schemas prevent
|
||||
the plugin from loading and fail config validation.
|
||||
Native OpenClaw plugins must ship `openclaw.plugin.json` with an inline JSON
|
||||
Schema (`configSchema`, even if empty). Compatible bundles use their own bundle
|
||||
manifests instead.
|
||||
|
||||
`plugins list` shows `Format: openclaw` or `Format: bundle`. Verbose list/info
|
||||
output also shows the bundle subtype (`codex`, `claude`, or `cursor`) plus detected bundle
|
||||
capabilities.
|
||||
|
||||
### Install
|
||||
|
||||
@ -60,6 +65,20 @@ name, use an explicit scoped spec (for example `@scope/diffs`).
|
||||
|
||||
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
|
||||
|
||||
For local paths and archives, OpenClaw auto-detects:
|
||||
|
||||
- native OpenClaw plugins (`openclaw.plugin.json`)
|
||||
- Codex-compatible bundles (`.codex-plugin/plugin.json`)
|
||||
- Claude-compatible bundles (`.claude-plugin/plugin.json` or the default Claude
|
||||
component layout)
|
||||
- Cursor-compatible bundles (`.cursor-plugin/plugin.json`)
|
||||
|
||||
Compatible bundles install into the normal extensions root and participate in
|
||||
the same list/info/enable/disable flow. Today, bundle skills, Claude
|
||||
command-skills, Claude `settings.json` defaults, Cursor command-skills, and compatible Codex hook
|
||||
directories are supported; other detected bundle capabilities are shown in
|
||||
diagnostics/info but are not yet wired into runtime execution.
|
||||
|
||||
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
|
||||
|
||||
```bash
|
||||
|
||||
@ -21,6 +21,7 @@ openclaw update wizard
|
||||
openclaw update --channel beta
|
||||
openclaw update --channel dev
|
||||
openclaw update --tag beta
|
||||
openclaw update --tag main
|
||||
openclaw update --dry-run
|
||||
openclaw update --no-restart
|
||||
openclaw update --json
|
||||
@ -31,7 +32,7 @@ openclaw --update
|
||||
|
||||
- `--no-restart`: skip restarting the Gateway service after a successful update.
|
||||
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
|
||||
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
|
||||
- `--tag <dist-tag|version|spec>`: override the package target for this update only. For package installs, `main` maps to `github:openclaw/openclaw#main`.
|
||||
- `--dry-run`: preview planned update actions (channel/tag/target/restart flow) without writing config, installing, syncing plugins, or restarting.
|
||||
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
||||
- `--timeout <seconds>`: per-step timeout (default is 1200s).
|
||||
|
||||
@ -16,6 +16,77 @@ 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 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`.
|
||||
- Provider plugins can also own provider runtime behavior via
|
||||
`resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`,
|
||||
`capabilities`, `prepareExtraParams`, `wrapStreamFn`,
|
||||
`isCacheTtlEligible`, `buildMissingAuthMessage`,
|
||||
`suppressBuiltInModel`, `augmentModelCatalog`, `prepareRuntimeAuth`,
|
||||
`resolveUsageAuth`, and `fetchUsageSnapshot`.
|
||||
|
||||
## Plugin-owned provider behavior
|
||||
|
||||
Provider plugins can now own most provider-specific logic while OpenClaw keeps
|
||||
the generic inference loop.
|
||||
|
||||
Typical split:
|
||||
|
||||
- `catalog`: provider appears in `models.providers`
|
||||
- `resolveDynamicModel`: provider accepts model ids not present in the local
|
||||
static catalog yet
|
||||
- `prepareDynamicModel`: provider needs a metadata refresh before retrying
|
||||
dynamic resolution
|
||||
- `normalizeResolvedModel`: provider needs transport or base URL rewrites
|
||||
- `capabilities`: provider publishes transcript/tooling/provider-family quirks
|
||||
- `prepareExtraParams`: provider defaults or normalizes per-model request params
|
||||
- `wrapStreamFn`: provider applies request headers/body/model compat wrappers
|
||||
- `isCacheTtlEligible`: provider decides which upstream model ids support prompt-cache TTL
|
||||
- `buildMissingAuthMessage`: provider replaces the generic auth-store error
|
||||
with a provider-specific recovery hint
|
||||
- `suppressBuiltInModel`: provider hides stale upstream rows and can return a
|
||||
vendor-owned error for direct resolution failures
|
||||
- `augmentModelCatalog`: provider appends synthetic/final catalog rows after
|
||||
discovery and config merging
|
||||
- `prepareRuntimeAuth`: provider turns a configured credential into a short
|
||||
lived runtime token
|
||||
- `resolveUsageAuth`: provider resolves usage/quota credentials for `/usage`
|
||||
and related status/reporting surfaces
|
||||
- `fetchUsageSnapshot`: provider owns the usage endpoint fetch/parsing while
|
||||
core still owns the summary shell and formatting
|
||||
|
||||
Current bundled examples:
|
||||
|
||||
- `anthropic`: Claude 4.6 forward-compat fallback, usage endpoint fetching,
|
||||
and cache-TTL/provider-family metadata
|
||||
- `openrouter`: pass-through model ids, request wrappers, provider capability
|
||||
hints, and cache-TTL policy
|
||||
- `github-copilot`: forward-compat model fallback, Claude-thinking transcript
|
||||
hints, runtime token exchange, and usage endpoint fetching
|
||||
- `openai`: GPT-5.4 forward-compat fallback, direct OpenAI transport
|
||||
normalization, Codex-aware missing-auth hints, Spark suppression, synthetic
|
||||
OpenAI/Codex catalog rows, and provider-family metadata
|
||||
- `google-gemini-cli`: Gemini 3.1 forward-compat fallback plus usage-token
|
||||
parsing and quota endpoint fetching for usage surfaces
|
||||
- `moonshot`: shared transport, plugin-owned thinking payload normalization
|
||||
- `kilocode`: shared transport, plugin-owned request headers, reasoning payload
|
||||
normalization, Gemini transcript hints, and cache-TTL policy
|
||||
- `zai`: GLM-5 forward-compat fallback, `tool_stream` defaults, cache-TTL
|
||||
policy, and usage auth + quota fetching
|
||||
- `mistral`, `opencode`, and `opencode-go`: plugin-owned capability metadata
|
||||
- `byteplus`, `cloudflare-ai-gateway`, `huggingface`, `kimi-coding`,
|
||||
`minimax-portal`, `modelstudio`, `nvidia`, `qianfan`, `qwen-portal`,
|
||||
`synthetic`, `together`, `venice`, `vercel-ai-gateway`, and `volcengine`:
|
||||
plugin-owned catalogs only
|
||||
- `minimax` and `xiaomi`: plugin-owned catalogs plus usage auth/snapshot logic
|
||||
|
||||
The bundled `openai` plugin now owns both provider ids: `openai` and
|
||||
`openai-codex`.
|
||||
|
||||
That covers providers that still fit OpenClaw's normal transports. A provider
|
||||
that needs a totally custom request executor is a separate, deeper extension
|
||||
surface.
|
||||
|
||||
## API key rotation
|
||||
|
||||
@ -114,16 +185,13 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
- Compatibility: legacy OpenClaw config using `google/gemini-3.1-flash-preview` is normalized to `google/gemini-3-flash-preview`
|
||||
- CLI: `openclaw onboard --auth-choice gemini-api-key`
|
||||
|
||||
### Google Vertex, Antigravity, and Gemini CLI
|
||||
### Google Vertex and Gemini CLI
|
||||
|
||||
- Providers: `google-vertex`, `google-antigravity`, `google-gemini-cli`
|
||||
- Auth: Vertex uses gcloud ADC; Antigravity/Gemini CLI use their respective auth flows
|
||||
- Caution: Antigravity and Gemini CLI OAuth in OpenClaw are unofficial integrations. Some users have reported Google account restrictions after using third-party clients. Review Google terms and use a non-critical account if you choose to proceed.
|
||||
- Antigravity OAuth is shipped as a bundled plugin (`google-antigravity-auth`, disabled by default).
|
||||
- Enable: `openclaw plugins enable google-antigravity-auth`
|
||||
- Login: `openclaw models auth login --provider google-antigravity --set-default`
|
||||
- Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default).
|
||||
- Enable: `openclaw plugins enable google-gemini-cli-auth`
|
||||
- Providers: `google-vertex`, `google-gemini-cli`
|
||||
- Auth: Vertex uses gcloud ADC; Gemini CLI uses its OAuth flow
|
||||
- Caution: Gemini CLI OAuth in OpenClaw is an unofficial integration. Some users have reported Google account restrictions after using third-party clients. Review Google terms and use a non-critical account if you choose to proceed.
|
||||
- Gemini CLI OAuth is shipped as part of the bundled `google` plugin.
|
||||
- Enable: `openclaw plugins enable google`
|
||||
- Login: `openclaw models auth login --provider google-gemini-cli --set-default`
|
||||
- Note: you do **not** paste a client id or secret into `openclaw.json`. The CLI login flow stores
|
||||
tokens in auth profiles on the gateway host.
|
||||
@ -154,12 +222,26 @@ OpenClaw ships with the pi‑ai catalog. These providers require **no**
|
||||
|
||||
See [/providers/kilocode](/providers/kilocode) for setup details.
|
||||
|
||||
### Other built-in providers
|
||||
### Other bundled provider plugins
|
||||
|
||||
- OpenRouter: `openrouter` (`OPENROUTER_API_KEY`)
|
||||
- Example model: `openrouter/anthropic/claude-sonnet-4-5`
|
||||
- Kilo Gateway: `kilocode` (`KILOCODE_API_KEY`)
|
||||
- Example model: `kilocode/anthropic/claude-opus-4.6`
|
||||
- MiniMax: `minimax` (`MINIMAX_API_KEY`)
|
||||
- Moonshot: `moonshot` (`MOONSHOT_API_KEY`)
|
||||
- Kimi Coding: `kimi-coding` (`KIMI_API_KEY` or `KIMICODE_API_KEY`)
|
||||
- Qianfan: `qianfan` (`QIANFAN_API_KEY`)
|
||||
- Model Studio: `modelstudio` (`MODELSTUDIO_API_KEY`)
|
||||
- NVIDIA: `nvidia` (`NVIDIA_API_KEY`)
|
||||
- Together: `together` (`TOGETHER_API_KEY`)
|
||||
- Venice: `venice` (`VENICE_API_KEY`)
|
||||
- Xiaomi: `xiaomi` (`XIAOMI_API_KEY`)
|
||||
- Vercel AI Gateway: `vercel-ai-gateway` (`AI_GATEWAY_API_KEY`)
|
||||
- Hugging Face Inference: `huggingface` (`HUGGINGFACE_HUB_TOKEN` or `HF_TOKEN`)
|
||||
- Cloudflare AI Gateway: `cloudflare-ai-gateway` (`CLOUDFLARE_AI_GATEWAY_API_KEY`)
|
||||
- Volcengine: `volcengine` (`VOLCANO_ENGINE_API_KEY`)
|
||||
- BytePlus: `byteplus` (`BYTEPLUS_API_KEY`)
|
||||
- xAI: `xai` (`XAI_API_KEY`)
|
||||
- Mistral: `mistral` (`MISTRAL_API_KEY`)
|
||||
- Example model: `mistral/mistral-large-latest`
|
||||
@ -169,13 +251,17 @@ See [/providers/kilocode](/providers/kilocode) for setup details.
|
||||
- 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: `huggingface` (`HUGGINGFACE_HUB_TOKEN` or `HF_TOKEN`) — OpenAI-compatible router; example model: `huggingface/deepseek-ai/DeepSeek-R1`; CLI: `openclaw onboard --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)
|
||||
|
||||
Use `models.providers` (or `models.json`) to add **custom** providers or
|
||||
OpenAI/Anthropic‑compatible proxies.
|
||||
|
||||
Many of the bundled provider plugins below already publish a default catalog.
|
||||
Use explicit `models.providers.<id>` entries only when you want to override the
|
||||
default base URL, headers, or model list.
|
||||
|
||||
### Moonshot AI (Kimi)
|
||||
|
||||
Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
|
||||
@ -235,10 +321,9 @@ Kimi Coding uses Moonshot AI's Anthropic-compatible endpoint:
|
||||
### Qwen OAuth (free tier)
|
||||
|
||||
Qwen provides OAuth access to Qwen Coder + Vision via a device-code flow.
|
||||
Enable the bundled plugin, then log in:
|
||||
The bundled provider plugin is enabled by default, so just log in:
|
||||
|
||||
```bash
|
||||
openclaw plugins enable qwen-portal-auth
|
||||
openclaw models auth login --provider qwen-portal --set-default
|
||||
```
|
||||
|
||||
|
||||
@ -469,7 +469,7 @@
|
||||
},
|
||||
{
|
||||
"source": "/mac/release",
|
||||
"destination": "/platforms/mac/release"
|
||||
"destination": "/reference/RELEASING"
|
||||
},
|
||||
{
|
||||
"source": "/mac/remote",
|
||||
@ -1046,6 +1046,7 @@
|
||||
"group": "Extensions",
|
||||
"pages": [
|
||||
"plugins/community",
|
||||
"plugins/bundles",
|
||||
"plugins/voice-call",
|
||||
"plugins/zalouser",
|
||||
"plugins/manifest",
|
||||
@ -1166,7 +1167,6 @@
|
||||
"platforms/mac/permissions",
|
||||
"platforms/mac/remote",
|
||||
"platforms/mac/signing",
|
||||
"platforms/mac/release",
|
||||
"platforms/mac/bundled-gateway",
|
||||
"platforms/mac/xpc",
|
||||
"platforms/mac/skills",
|
||||
@ -1351,7 +1351,7 @@
|
||||
"pages": ["reference/credits"]
|
||||
},
|
||||
{
|
||||
"group": "Release notes",
|
||||
"group": "Release policy",
|
||||
"pages": ["reference/RELEASING", "reference/test"]
|
||||
},
|
||||
{
|
||||
@ -1750,7 +1750,6 @@
|
||||
"zh-CN/platforms/mac/permissions",
|
||||
"zh-CN/platforms/mac/remote",
|
||||
"zh-CN/platforms/mac/signing",
|
||||
"zh-CN/platforms/mac/release",
|
||||
"zh-CN/platforms/mac/bundled-gateway",
|
||||
"zh-CN/platforms/mac/xpc",
|
||||
"zh-CN/platforms/mac/skills",
|
||||
@ -1933,7 +1932,7 @@
|
||||
"pages": ["zh-CN/reference/credits"]
|
||||
},
|
||||
{
|
||||
"group": "发布说明",
|
||||
"group": "发布策略",
|
||||
"pages": ["zh-CN/reference/RELEASING", "zh-CN/reference/test"]
|
||||
},
|
||||
{
|
||||
|
||||
@ -1005,6 +1005,7 @@ Periodic heartbeat runs.
|
||||
defaults: {
|
||||
compaction: {
|
||||
mode: "safeguard", // default | safeguard
|
||||
timeoutSeconds: 900,
|
||||
reserveTokensFloor: 24000,
|
||||
identifierPolicy: "strict", // strict | off | custom
|
||||
identifierInstructions: "Preserve deployment IDs, ticket IDs, and host:port pairs exactly.", // used when identifierPolicy=custom
|
||||
@ -1023,6 +1024,7 @@ Periodic heartbeat runs.
|
||||
```
|
||||
|
||||
- `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/concepts/compaction).
|
||||
- `timeoutSeconds`: maximum seconds allowed for a single compaction operation before OpenClaw aborts it. Default: `900`.
|
||||
- `identifierPolicy`: `strict` (default), `off`, or `custom`. `strict` prepends built-in opaque identifier retention guidance during compaction summarization.
|
||||
- `identifierInstructions`: optional custom identifier-preservation text used when `identifierPolicy=custom`.
|
||||
- `postCompactionSections`: optional AGENTS.md H2/H3 section names to re-inject after compaction. Defaults to `["Session Startup", "Red Lines"]`; set `[]` to disable reinjection. When unset or explicitly set to that default pair, older `Every Session`/`Safety` headings are also accepted as a legacy fallback.
|
||||
@ -2321,12 +2323,14 @@ See [Local Models](/gateway/local-models). TL;DR: run MiniMax M2.5 via LM Studio
|
||||
```
|
||||
|
||||
- Loaded from `~/.openclaw/extensions`, `<workspace>/.openclaw/extensions`, plus `plugins.load.paths`.
|
||||
- Discovery accepts native OpenClaw plugins plus compatible Codex bundles and Claude bundles, including manifestless Claude default-layout bundles.
|
||||
- **Config changes require a gateway restart.**
|
||||
- `allow`: optional allowlist (only listed plugins load). `deny` wins.
|
||||
- `plugins.entries.<id>.apiKey`: plugin-level API key convenience field (when supported by the plugin).
|
||||
- `plugins.entries.<id>.env`: plugin-scoped env var map.
|
||||
- `plugins.entries.<id>.hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`.
|
||||
- `plugins.entries.<id>.config`: plugin-defined config object (validated by plugin schema).
|
||||
- `plugins.entries.<id>.hooks.allowPromptInjection`: when `false`, core blocks `before_prompt_build` and ignores prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride`. Applies to native plugin hooks and supported bundle-provided hook directories.
|
||||
- `plugins.entries.<id>.config`: plugin-defined config object (validated by native OpenClaw plugin schema when available).
|
||||
- Enabled Claude bundle plugins can also contribute embedded Pi defaults from `settings.json`; OpenClaw applies those as sanitized agent settings, not as raw OpenClaw config patches.
|
||||
- `plugins.slots.memory`: pick the active memory plugin id, or `"none"` to disable memory plugins.
|
||||
- `plugins.slots.contextEngine`: pick the active context engine plugin id; defaults to `"legacy"` unless you install and select another engine.
|
||||
- `plugins.installs`: CLI-managed install metadata used by `openclaw plugins update`.
|
||||
@ -2488,6 +2492,11 @@ See [Plugins](/tools/plugin).
|
||||
- Relay-backed registrations are delegated to a specific gateway identity. The paired iOS app fetches `gateway.identity.get`, includes that identity in the relay registration, and forwards a registration-scoped send grant to the gateway. Another gateway cannot reuse that stored registration.
|
||||
- `OPENCLAW_APNS_RELAY_BASE_URL` / `OPENCLAW_APNS_RELAY_TIMEOUT_MS`: temporary env overrides for the relay config above.
|
||||
- `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true`: development-only escape hatch for loopback HTTP relay URLs. Production relay URLs should stay on HTTPS.
|
||||
- `gateway.channelHealthCheckMinutes`: channel health-monitor interval in minutes. Set `0` to disable health-monitor restarts globally. Default: `5`.
|
||||
- `gateway.channelStaleEventThresholdMinutes`: stale-socket threshold in minutes. Keep this greater than or equal to `gateway.channelHealthCheckMinutes`. Default: `30`.
|
||||
- `gateway.channelMaxRestartsPerHour`: maximum health-monitor restarts per channel/account in a rolling hour. Default: `10`.
|
||||
- `channels.<provider>.healthMonitor.enabled`: per-channel opt-out for health-monitor restarts while keeping the global monitor enabled.
|
||||
- `channels.<provider>.accounts.<accountId>.healthMonitor.enabled`: per-account override for multi-account channels. When set, it takes precedence over the channel-level override.
|
||||
- Local gateway call paths can use `gateway.remote.*` as fallback only when `gateway.auth.*` is unset.
|
||||
- If `gateway.auth.token` / `gateway.auth.password` is explicitly configured via SecretRef and unresolved, resolution fails closed (no remote fallback masking).
|
||||
- `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.
|
||||
|
||||
@ -175,6 +175,36 @@ When validation fails:
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Tune gateway channel health monitoring">
|
||||
Control how aggressively the gateway restarts channels that look stale:
|
||||
|
||||
```json5
|
||||
{
|
||||
gateway: {
|
||||
channelHealthCheckMinutes: 5,
|
||||
channelStaleEventThresholdMinutes: 30,
|
||||
channelMaxRestartsPerHour: 10,
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
healthMonitor: { enabled: false },
|
||||
accounts: {
|
||||
alerts: {
|
||||
healthMonitor: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Set `gateway.channelHealthCheckMinutes: 0` to disable health-monitor restarts globally.
|
||||
- `channelStaleEventThresholdMinutes` should be greater than or equal to the check interval.
|
||||
- Use `channels.<provider>.healthMonitor.enabled` or `channels.<provider>.accounts.<id>.healthMonitor.enabled` to disable auto-restarts for one channel or account without disabling the global monitor.
|
||||
- See [Health Checks](/gateway/health) for operational debugging and the [full reference](/gateway/configuration-reference#gateway) for all fields.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Configure sessions and resets">
|
||||
Sessions control conversation continuity and isolation:
|
||||
|
||||
|
||||
@ -24,6 +24,15 @@ Short guide to verify channel connectivity without guessing.
|
||||
- Session store: `ls -l ~/.openclaw/agents/<agentId>/sessions/sessions.json` (path can be overridden in config). Count and recent recipients are surfaced via `status`.
|
||||
- Relink flow: `openclaw channels logout && openclaw channels login --verbose` when status codes 409–515 or `loggedOut` appear in logs. (Note: the QR login flow auto-restarts once for status 515 after pairing.)
|
||||
|
||||
## Health monitor config
|
||||
|
||||
- `gateway.channelHealthCheckMinutes`: how often the gateway checks channel health. Default: `5`. Set `0` to disable health-monitor restarts globally.
|
||||
- `gateway.channelStaleEventThresholdMinutes`: how long a connected channel can stay idle before the health monitor treats it as stale and restarts it. Default: `30`. Keep this greater than or equal to `gateway.channelHealthCheckMinutes`.
|
||||
- `gateway.channelMaxRestartsPerHour`: rolling one-hour cap for health-monitor restarts per channel/account. Default: `10`.
|
||||
- `channels.<provider>.healthMonitor.enabled`: disable health-monitor restarts for a specific channel while leaving global monitoring enabled.
|
||||
- `channels.<provider>.accounts.<accountId>.healthMonitor.enabled`: multi-account override that wins over the channel-level setting.
|
||||
- These per-channel overrides apply to the built-in channel monitors that expose them today: Discord, Google Chat, iMessage, Microsoft Teams, Signal, Slack, Telegram, and WhatsApp.
|
||||
|
||||
## When something fails
|
||||
|
||||
- `logged out` or status 409–515 → relink with `openclaw channels logout` then `openclaw channels login`.
|
||||
|
||||
@ -40,11 +40,17 @@ pnpm gateway:watch
|
||||
This maps to:
|
||||
|
||||
```bash
|
||||
node --watch-path src --watch-path tsconfig.json --watch-path package.json --watch-preserve-output scripts/run-node.mjs gateway --force
|
||||
node scripts/watch-node.mjs gateway --force
|
||||
```
|
||||
|
||||
Add any gateway CLI flags after `gateway:watch` and they will be passed through
|
||||
on each restart.
|
||||
The watcher restarts on build-relevant files under `src/`, extension source files,
|
||||
extension `package.json` and `openclaw.plugin.json` metadata, `tsconfig.json`,
|
||||
`package.json`, and `tsdown.config.ts`. Extension metadata changes restart the
|
||||
gateway without forcing a `tsdown` rebuild; source and config changes still
|
||||
rebuild `dist` first.
|
||||
|
||||
Add any gateway CLI flags after `gateway:watch` and they will be passed through on
|
||||
each restart.
|
||||
|
||||
## Dev profile + dev gateway (--dev)
|
||||
|
||||
|
||||
@ -783,7 +783,7 @@ Gemini CLI uses a **plugin auth flow**, not a client id or secret in `openclaw.j
|
||||
|
||||
Steps:
|
||||
|
||||
1. Enable the plugin: `openclaw plugins enable google-gemini-cli-auth`
|
||||
1. Enable the plugin: `openclaw plugins enable google`
|
||||
2. Login: `openclaw models auth login --provider google-gemini-cli --set-default`
|
||||
|
||||
This stores OAuth tokens in auth profiles on the gateway host. Details: [Model providers](/concepts/model-providers).
|
||||
|
||||
@ -102,6 +102,16 @@ For VPS/cloud hosts, avoid third-party "1-click" marketplace images when possibl
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Want the current GitHub `main` head with a package-manager install?
|
||||
|
||||
```bash
|
||||
npm install -g github:openclaw/openclaw#main
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm add -g github:openclaw/openclaw#main
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="From source" icon="github">
|
||||
|
||||
@ -116,6 +116,11 @@ The script exits with code `2` for invalid method selection or invalid `--instal
|
||||
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --install-method git
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="GitHub main via npm">
|
||||
```bash
|
||||
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --version main
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Dry run">
|
||||
```bash
|
||||
curl -fsSL --proto '=https' --tlsv1.2 https://openclaw.ai/install.sh | bash -s -- --dry-run
|
||||
@ -126,39 +131,39 @@ The script exits with code `2` for invalid method selection or invalid `--instal
|
||||
<AccordionGroup>
|
||||
<Accordion title="Flags reference">
|
||||
|
||||
| Flag | Description |
|
||||
| ------------------------------- | ---------------------------------------------------------- |
|
||||
| `--install-method npm\|git` | Choose install method (default: `npm`). Alias: `--method` |
|
||||
| `--npm` | Shortcut for npm method |
|
||||
| `--git` | Shortcut for git method. Alias: `--github` |
|
||||
| `--version <version\|dist-tag>` | npm version or dist-tag (default: `latest`) |
|
||||
| `--beta` | Use beta dist-tag if available, else fallback to `latest` |
|
||||
| `--git-dir <path>` | Checkout directory (default: `~/openclaw`). Alias: `--dir` |
|
||||
| `--no-git-update` | Skip `git pull` for existing checkout |
|
||||
| `--no-prompt` | Disable prompts |
|
||||
| `--no-onboard` | Skip onboarding |
|
||||
| `--onboard` | Enable onboarding |
|
||||
| `--dry-run` | Print actions without applying changes |
|
||||
| `--verbose` | Enable debug output (`set -x`, npm notice-level logs) |
|
||||
| `--help` | Show usage (`-h`) |
|
||||
| Flag | Description |
|
||||
| ------------------------------------- | ---------------------------------------------------------- |
|
||||
| `--install-method npm\|git` | Choose install method (default: `npm`). Alias: `--method` |
|
||||
| `--npm` | Shortcut for npm method |
|
||||
| `--git` | Shortcut for git method. Alias: `--github` |
|
||||
| `--version <version\|dist-tag\|spec>` | npm version, dist-tag, or package spec (default: `latest`) |
|
||||
| `--beta` | Use beta dist-tag if available, else fallback to `latest` |
|
||||
| `--git-dir <path>` | Checkout directory (default: `~/openclaw`). Alias: `--dir` |
|
||||
| `--no-git-update` | Skip `git pull` for existing checkout |
|
||||
| `--no-prompt` | Disable prompts |
|
||||
| `--no-onboard` | Skip onboarding |
|
||||
| `--onboard` | Enable onboarding |
|
||||
| `--dry-run` | Print actions without applying changes |
|
||||
| `--verbose` | Enable debug output (`set -x`, npm notice-level logs) |
|
||||
| `--help` | Show usage (`-h`) |
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Environment variables reference">
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------------------- | --------------------------------------------- |
|
||||
| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method |
|
||||
| `OPENCLAW_VERSION=latest\|next\|<semver>` | npm version or dist-tag |
|
||||
| `OPENCLAW_BETA=0\|1` | Use beta if available |
|
||||
| `OPENCLAW_GIT_DIR=<path>` | Checkout directory |
|
||||
| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates |
|
||||
| `OPENCLAW_NO_PROMPT=1` | Disable prompts |
|
||||
| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding |
|
||||
| `OPENCLAW_DRY_RUN=1` | Dry run mode |
|
||||
| `OPENCLAW_VERBOSE=1` | Debug mode |
|
||||
| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level |
|
||||
| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) |
|
||||
| Variable | Description |
|
||||
| ------------------------------------------------------- | --------------------------------------------- |
|
||||
| `OPENCLAW_INSTALL_METHOD=git\|npm` | Install method |
|
||||
| `OPENCLAW_VERSION=latest\|next\|main\|<semver>\|<spec>` | npm version, dist-tag, or package spec |
|
||||
| `OPENCLAW_BETA=0\|1` | Use beta if available |
|
||||
| `OPENCLAW_GIT_DIR=<path>` | Checkout directory |
|
||||
| `OPENCLAW_GIT_UPDATE=0\|1` | Toggle git updates |
|
||||
| `OPENCLAW_NO_PROMPT=1` | Disable prompts |
|
||||
| `OPENCLAW_NO_ONBOARD=1` | Skip onboarding |
|
||||
| `OPENCLAW_DRY_RUN=1` | Dry run mode |
|
||||
| `OPENCLAW_VERBOSE=1` | Debug mode |
|
||||
| `OPENCLAW_NPM_LOGLEVEL=error\|warn\|notice` | npm log level |
|
||||
| `SHARP_IGNORE_GLOBAL_LIBVIPS=0\|1` | Control sharp/libvips behavior (default: `1`) |
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
@ -276,6 +281,11 @@ Designed for environments where you want everything under a local prefix (defaul
|
||||
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -InstallMethod git
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="GitHub main via npm">
|
||||
```powershell
|
||||
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -Tag main
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Custom git directory">
|
||||
```powershell
|
||||
& ([scriptblock]::Create((iwr -useb https://openclaw.ai/install.ps1))) -InstallMethod git -GitDir "C:\openclaw"
|
||||
@ -299,14 +309,14 @@ Designed for environments where you want everything under a local prefix (defaul
|
||||
<AccordionGroup>
|
||||
<Accordion title="Flags reference">
|
||||
|
||||
| Flag | Description |
|
||||
| ------------------------- | ------------------------------------------------------ |
|
||||
| `-InstallMethod npm\|git` | Install method (default: `npm`) |
|
||||
| `-Tag <tag>` | npm dist-tag (default: `latest`) |
|
||||
| `-GitDir <path>` | Checkout directory (default: `%USERPROFILE%\openclaw`) |
|
||||
| `-NoOnboard` | Skip onboarding |
|
||||
| `-NoGitUpdate` | Skip `git pull` |
|
||||
| `-DryRun` | Print actions only |
|
||||
| Flag | Description |
|
||||
| --------------------------- | ---------------------------------------------------------- |
|
||||
| `-InstallMethod npm\|git` | Install method (default: `npm`) |
|
||||
| `-Tag <tag\|version\|spec>` | npm dist-tag, version, or package spec (default: `latest`) |
|
||||
| `-GitDir <path>` | Checkout directory (default: `%USERPROFILE%\openclaw`) |
|
||||
| `-NoOnboard` | Skip onboarding |
|
||||
| `-NoGitUpdate` | Skip `git pull` |
|
||||
| `-DryRun` | Print actions only |
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@ -65,7 +65,25 @@ openclaw update --channel dev
|
||||
openclaw update --channel stable
|
||||
```
|
||||
|
||||
Use `--tag <dist-tag|version>` for a one-off install tag/version.
|
||||
Use `--tag <dist-tag|version|spec>` for a one-off package target override.
|
||||
|
||||
For the current GitHub `main` head via a package-manager install:
|
||||
|
||||
```bash
|
||||
openclaw update --tag main
|
||||
```
|
||||
|
||||
Manual equivalents:
|
||||
|
||||
```bash
|
||||
npm i -g github:openclaw/openclaw#main
|
||||
```
|
||||
|
||||
```bash
|
||||
pnpm add -g github:openclaw/openclaw#main
|
||||
```
|
||||
|
||||
You can also pass an explicit package spec to `--tag` for one-off updates (for example a GitHub ref or tarball URL).
|
||||
|
||||
See [Development channels](/install/development-channels) for channel semantics and release notes.
|
||||
|
||||
|
||||
@ -1,90 +0,0 @@
|
||||
---
|
||||
summary: "OpenClaw macOS release checklist (Sparkle feed, packaging, signing)"
|
||||
read_when:
|
||||
- Cutting or validating a OpenClaw macOS release
|
||||
- Updating the Sparkle appcast or feed assets
|
||||
title: "macOS Release"
|
||||
---
|
||||
|
||||
# OpenClaw macOS release (Sparkle)
|
||||
|
||||
This app now ships Sparkle auto-updates. Release builds must be Developer ID–signed, zipped, and published with a signed appcast entry.
|
||||
|
||||
## Prereqs
|
||||
|
||||
- Developer ID Application cert installed (example: `Developer ID Application: <Developer Name> (<TEAMID>)`).
|
||||
- Sparkle private key path set in the environment as `SPARKLE_PRIVATE_KEY_FILE` (path to your Sparkle ed25519 private key; public key baked into Info.plist). If it is missing, check `~/.profile`.
|
||||
- Notary credentials (keychain profile or API key) for `xcrun notarytool` if you want Gatekeeper-safe DMG/zip distribution.
|
||||
- We use a Keychain profile named `openclaw-notary`, created from App Store Connect API key env vars in your shell profile:
|
||||
- `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`
|
||||
- `echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/openclaw-notary.p8`
|
||||
- `xcrun notarytool store-credentials "openclaw-notary" --key /tmp/openclaw-notary.p8 --key-id "$APP_STORE_CONNECT_KEY_ID" --issuer "$APP_STORE_CONNECT_ISSUER_ID"`
|
||||
- `pnpm` deps installed (`pnpm install --config.node-linker=hoisted`).
|
||||
- Sparkle tools are fetched automatically via SwiftPM at `apps/macos/.build/artifacts/sparkle/Sparkle/bin/` (`sign_update`, `generate_appcast`, etc.).
|
||||
|
||||
## Build & package
|
||||
|
||||
Notes:
|
||||
|
||||
- `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal.
|
||||
- If `APP_BUILD` is omitted, `scripts/package-mac-app.sh` derives a Sparkle-safe default from `APP_VERSION` (`YYYYMMDDNN`: stable defaults to `90`, prereleases use a suffix-derived lane) and uses the higher of that value and git commit count.
|
||||
- You can still override `APP_BUILD` explicitly when release engineering needs a specific monotonic value.
|
||||
- For `BUILD_CONFIG=release`, `scripts/package-mac-app.sh` now defaults to universal (`arm64 x86_64`) automatically. You can still override with `BUILD_ARCHS=arm64` or `BUILD_ARCHS=x86_64`. For local/dev builds (`BUILD_CONFIG=debug`), it defaults to the current architecture (`$(uname -m)`).
|
||||
- Use `scripts/package-mac-dist.sh` for release artifacts (zip + DMG + notarization). Use `scripts/package-mac-app.sh` for local/dev packaging.
|
||||
|
||||
```bash
|
||||
# From repo root; set release IDs so Sparkle feed is enabled.
|
||||
# This command builds release artifacts without notarization.
|
||||
# APP_BUILD must be numeric + monotonic for Sparkle compare.
|
||||
# Default is auto-derived from APP_VERSION when omitted.
|
||||
SKIP_NOTARIZE=1 \
|
||||
BUNDLE_ID=ai.openclaw.mac \
|
||||
APP_VERSION=2026.3.13 \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# `package-mac-dist.sh` already creates the zip + DMG.
|
||||
# If you used `package-mac-app.sh` directly instead, create them manually:
|
||||
# If you want notarization/stapling in this step, use the NOTARIZE command below.
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.13.zip
|
||||
|
||||
# Optional: build a styled DMG for humans (drag to /Applications)
|
||||
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.13.dmg
|
||||
|
||||
# Recommended: build + notarize/staple zip + DMG
|
||||
# First, create a keychain profile once:
|
||||
# xcrun notarytool store-credentials "openclaw-notary" \
|
||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
|
||||
BUNDLE_ID=ai.openclaw.mac \
|
||||
APP_VERSION=2026.3.13 \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# Optional: ship dSYM alongside the release
|
||||
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.13.dSYM.zip
|
||||
```
|
||||
|
||||
## Appcast entry
|
||||
|
||||
Use the release note generator so Sparkle renders formatted HTML notes:
|
||||
|
||||
```bash
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.3.13.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
|
||||
```
|
||||
|
||||
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
|
||||
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
|
||||
|
||||
## Publish & verify
|
||||
|
||||
- Upload `OpenClaw-2026.3.13.zip` (and `OpenClaw-2026.3.13.dSYM.zip`) to the GitHub release for tag `v2026.3.13`.
|
||||
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
|
||||
- Sanity checks:
|
||||
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.
|
||||
- `curl -I <enclosure url>` returns 200 after assets upload.
|
||||
- On a previous public build, run “Check for Updates…” from the About tab and verify Sparkle installs the new build cleanly.
|
||||
|
||||
Definition of done: signed app + appcast are published, update flow works from an older installed version, and release assets are attached to the GitHub release.
|
||||
292
docs/plugins/bundles.md
Normal file
292
docs/plugins/bundles.md
Normal file
@ -0,0 +1,292 @@
|
||||
---
|
||||
summary: "Unified bundle format guide for Codex, Claude, and Cursor bundles in OpenClaw"
|
||||
read_when:
|
||||
- You want to install or debug a Codex, Claude, or Cursor-compatible bundle
|
||||
- You need to understand how OpenClaw maps bundle content into native features
|
||||
- You are documenting bundle compatibility or current support limits
|
||||
title: "Plugin Bundles"
|
||||
---
|
||||
|
||||
# Plugin bundles
|
||||
|
||||
OpenClaw supports one shared class of external plugin package: **bundle
|
||||
plugins**.
|
||||
|
||||
Today that means three closely related ecosystems:
|
||||
|
||||
- Codex bundles
|
||||
- Claude bundles
|
||||
- Cursor bundles
|
||||
|
||||
OpenClaw shows all of them as `Format: bundle` in `openclaw plugins list`.
|
||||
Verbose output and `openclaw plugins info <id>` also show the subtype
|
||||
(`codex`, `claude`, or `cursor`).
|
||||
|
||||
Related:
|
||||
|
||||
- Plugin system overview: [Plugins](/tools/plugin)
|
||||
- CLI install/list flows: [plugins](/cli/plugins)
|
||||
- Native manifest schema: [Plugin manifest](/plugins/manifest)
|
||||
|
||||
## What a bundle is
|
||||
|
||||
A bundle is a **content/metadata pack**, not a native in-process OpenClaw
|
||||
plugin.
|
||||
|
||||
Today, OpenClaw does **not** execute bundle runtime code in-process. Instead,
|
||||
it detects known bundle files, reads the metadata, and maps supported bundle
|
||||
content into native OpenClaw surfaces such as skills, hook packs, MCP config,
|
||||
and embedded Pi settings.
|
||||
|
||||
That is the main trust boundary:
|
||||
|
||||
- native OpenClaw plugin: runtime module executes in-process
|
||||
- bundle: metadata/content pack, with selective feature mapping
|
||||
|
||||
## Shared bundle model
|
||||
|
||||
Codex, Claude, and Cursor bundles are similar enough that OpenClaw treats them
|
||||
as one normalized model.
|
||||
|
||||
Shared idea:
|
||||
|
||||
- a small manifest file, or a default directory layout
|
||||
- one or more content roots such as `skills/` or `commands/`
|
||||
- optional tool/runtime metadata such as MCP, hooks, agents, or LSP
|
||||
- install as a directory or archive, then enable in the normal plugin list
|
||||
|
||||
Common OpenClaw behavior:
|
||||
|
||||
- detect the bundle subtype
|
||||
- normalize it into one internal bundle record
|
||||
- map supported parts into native OpenClaw features
|
||||
- report unsupported parts as detected-but-not-wired capabilities
|
||||
|
||||
In practice, most users do not need to think about the vendor-specific format
|
||||
first. The more useful question is: which bundle surfaces does OpenClaw map
|
||||
today?
|
||||
|
||||
## Detection order
|
||||
|
||||
OpenClaw prefers native OpenClaw plugin/package layouts before bundle handling.
|
||||
|
||||
Practical effect:
|
||||
|
||||
- `openclaw.plugin.json` wins over bundle detection
|
||||
- package installs with valid `package.json` + `openclaw.extensions` use the
|
||||
native install path
|
||||
- if a directory contains both native and bundle metadata, OpenClaw treats it
|
||||
as native first
|
||||
|
||||
That avoids partially installing a dual-format package as a bundle and then
|
||||
loading it later as a native plugin.
|
||||
|
||||
## What works today
|
||||
|
||||
OpenClaw normalizes bundle metadata into one internal bundle record, then maps
|
||||
supported surfaces into existing native behavior.
|
||||
|
||||
### Supported now
|
||||
|
||||
#### Skill content
|
||||
|
||||
- bundle skill roots load as normal OpenClaw skill roots
|
||||
- Claude `commands` roots are treated as additional skill roots
|
||||
- Cursor `.cursor/commands` roots are treated as additional skill roots
|
||||
|
||||
This means Claude markdown command files work through the normal OpenClaw skill
|
||||
loader. Cursor command markdown works through the same path.
|
||||
|
||||
#### Hook packs
|
||||
|
||||
- bundle hook roots work **only** when they use the normal OpenClaw hook-pack
|
||||
layout. Today this is primarily the Codex-compatible case:
|
||||
- `HOOK.md`
|
||||
- `handler.ts` or `handler.js`
|
||||
|
||||
#### MCP for CLI backends
|
||||
|
||||
- enabled bundles can contribute MCP server config
|
||||
- current runtime wiring is used by the `claude-cli` backend
|
||||
- OpenClaw merges bundle MCP config into the backend `--mcp-config` file
|
||||
|
||||
#### Embedded Pi settings
|
||||
|
||||
- Claude `settings.json` is imported as default embedded Pi settings when the
|
||||
bundle is enabled
|
||||
- OpenClaw sanitizes shell override keys before applying them
|
||||
|
||||
Sanitized keys:
|
||||
|
||||
- `shellPath`
|
||||
- `shellCommandPrefix`
|
||||
|
||||
### Detected but not executed
|
||||
|
||||
These surfaces are detected, shown in bundle capabilities, and may appear in
|
||||
diagnostics/info output, but OpenClaw does not run them yet:
|
||||
|
||||
- Claude `agents`
|
||||
- Claude `hooks.json` automation
|
||||
- Claude `lspServers`
|
||||
- Claude `outputStyles`
|
||||
- Cursor `.cursor/agents`
|
||||
- Cursor `.cursor/hooks.json`
|
||||
- Cursor `.cursor/rules`
|
||||
- Cursor `mcpServers` outside the current mapped runtime paths
|
||||
- Codex inline/app metadata beyond capability reporting
|
||||
|
||||
## Capability reporting
|
||||
|
||||
`openclaw plugins info <id>` shows bundle capabilities from the normalized
|
||||
bundle record.
|
||||
|
||||
Supported capabilities are loaded quietly. Unsupported capabilities produce a
|
||||
warning such as:
|
||||
|
||||
```text
|
||||
bundle capability detected but not wired into OpenClaw yet: agents
|
||||
```
|
||||
|
||||
Current exceptions:
|
||||
|
||||
- Claude `commands` is considered supported because it maps to skills
|
||||
- Claude `settings` is considered supported because it maps to embedded Pi settings
|
||||
- Cursor `commands` is considered supported because it maps to skills
|
||||
- bundle MCP is considered supported where OpenClaw actually imports it
|
||||
- Codex `hooks` is considered supported only for OpenClaw hook-pack layouts
|
||||
|
||||
## Format differences
|
||||
|
||||
The formats are close, but not byte-for-byte identical. These are the practical
|
||||
differences that matter in OpenClaw.
|
||||
|
||||
### Codex
|
||||
|
||||
Typical markers:
|
||||
|
||||
- `.codex-plugin/plugin.json`
|
||||
- optional `skills/`
|
||||
- optional `hooks/`
|
||||
- optional `.mcp.json`
|
||||
- optional `.app.json`
|
||||
|
||||
Codex bundles fit OpenClaw best when they use skill roots and OpenClaw-style
|
||||
hook-pack directories.
|
||||
|
||||
### Claude
|
||||
|
||||
OpenClaw supports both:
|
||||
|
||||
- manifest-based Claude bundles: `.claude-plugin/plugin.json`
|
||||
- manifestless Claude bundles that use the default Claude layout
|
||||
|
||||
Default Claude layout markers OpenClaw recognizes:
|
||||
|
||||
- `skills/`
|
||||
- `commands/`
|
||||
- `agents/`
|
||||
- `hooks/hooks.json`
|
||||
- `.mcp.json`
|
||||
- `.lsp.json`
|
||||
- `settings.json`
|
||||
|
||||
Claude-specific notes:
|
||||
|
||||
- `commands/` is treated like skill content
|
||||
- `settings.json` is imported into embedded Pi settings
|
||||
- `hooks/hooks.json` is detected, but not executed as Claude automation
|
||||
|
||||
### Cursor
|
||||
|
||||
Typical markers:
|
||||
|
||||
- `.cursor-plugin/plugin.json`
|
||||
- optional `skills/`
|
||||
- optional `.cursor/commands/`
|
||||
- optional `.cursor/agents/`
|
||||
- optional `.cursor/rules/`
|
||||
- optional `.cursor/hooks.json`
|
||||
- optional `.mcp.json`
|
||||
|
||||
Cursor-specific notes:
|
||||
|
||||
- `.cursor/commands/` is treated like skill content
|
||||
- `.cursor/rules/`, `.cursor/agents/`, and `.cursor/hooks.json` are
|
||||
detect-only today
|
||||
|
||||
## Claude custom paths
|
||||
|
||||
Claude bundle manifests can declare custom component paths. OpenClaw treats
|
||||
those paths as **additive**, not replacing defaults.
|
||||
|
||||
Currently recognized custom path keys:
|
||||
|
||||
- `skills`
|
||||
- `commands`
|
||||
- `agents`
|
||||
- `hooks`
|
||||
- `mcpServers`
|
||||
- `lspServers`
|
||||
- `outputStyles`
|
||||
|
||||
Examples:
|
||||
|
||||
- default `commands/` plus manifest `commands: "extra-commands"` =>
|
||||
OpenClaw scans both
|
||||
- default `skills/` plus manifest `skills: ["team-skills"]` =>
|
||||
OpenClaw scans both
|
||||
|
||||
## Security model
|
||||
|
||||
Bundle support is intentionally narrower than native plugin support.
|
||||
|
||||
Current behavior:
|
||||
|
||||
- bundle discovery reads files inside the plugin root with boundary checks
|
||||
- skills and hook-pack paths must stay inside the plugin root
|
||||
- bundle settings files are read with the same boundary checks
|
||||
- OpenClaw does not execute arbitrary bundle runtime code in-process
|
||||
|
||||
This makes bundle support safer by default than native plugin modules, but you
|
||||
should still treat third-party bundles as trusted content for the features they
|
||||
do expose.
|
||||
|
||||
## Install examples
|
||||
|
||||
```bash
|
||||
openclaw plugins install ./my-codex-bundle
|
||||
openclaw plugins install ./my-claude-bundle
|
||||
openclaw plugins install ./my-cursor-bundle
|
||||
openclaw plugins install ./my-bundle.tgz
|
||||
openclaw plugins info my-bundle
|
||||
```
|
||||
|
||||
If the directory is a native OpenClaw plugin/package, the native install path
|
||||
still wins.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Bundle is detected but capabilities do not run
|
||||
|
||||
Check `openclaw plugins info <id>`.
|
||||
|
||||
If the capability is listed but OpenClaw says it is not wired yet, that is a
|
||||
real product limit, not a broken install.
|
||||
|
||||
### Claude command files do not appear
|
||||
|
||||
Make sure the bundle is enabled and the markdown files are inside a detected
|
||||
`commands` root or `skills` root.
|
||||
|
||||
### Claude settings do not apply
|
||||
|
||||
Current support is limited to embedded Pi settings from `settings.json`.
|
||||
OpenClaw does not treat bundle settings as raw OpenClaw config patches.
|
||||
|
||||
### Claude hooks do not execute
|
||||
|
||||
`hooks/hooks.json` is only detected today.
|
||||
|
||||
If you need runnable bundle hooks today, use the normal OpenClaw hook-pack
|
||||
layout through a supported Codex hook root or ship a native OpenClaw plugin.
|
||||
@ -8,10 +8,28 @@ title: "Plugin Manifest"
|
||||
|
||||
# Plugin manifest (openclaw.plugin.json)
|
||||
|
||||
Every plugin **must** ship a `openclaw.plugin.json` file in the **plugin root**.
|
||||
OpenClaw uses this manifest to validate configuration **without executing plugin
|
||||
code**. Missing or invalid manifests are treated as plugin errors and block
|
||||
config validation.
|
||||
This page is for the **native OpenClaw plugin manifest** only.
|
||||
|
||||
For compatible bundle layouts, see [Plugin bundles](/plugins/bundles).
|
||||
|
||||
Compatible bundle formats use different manifest files:
|
||||
|
||||
- Codex bundle: `.codex-plugin/plugin.json`
|
||||
- Claude bundle: `.claude-plugin/plugin.json` or the default Claude component
|
||||
layout without a manifest
|
||||
- Cursor bundle: `.cursor-plugin/plugin.json`
|
||||
|
||||
OpenClaw auto-detects those bundle layouts too, but they are not validated
|
||||
against the `openclaw.plugin.json` schema described here.
|
||||
|
||||
For compatible bundles, OpenClaw currently reads bundle metadata plus declared
|
||||
skill roots, Claude command roots, Claude bundle `settings.json` defaults, and
|
||||
supported hook packs when the layout matches OpenClaw runtime expectations.
|
||||
|
||||
Every native OpenClaw plugin **must** ship a `openclaw.plugin.json` file in the
|
||||
**plugin root**. OpenClaw uses this manifest to validate configuration
|
||||
**without executing plugin code**. Missing or invalid manifests are treated as
|
||||
plugin errors and block config validation.
|
||||
|
||||
See the full plugin system guide: [Plugins](/tools/plugin).
|
||||
|
||||
@ -63,7 +81,7 @@ Optional keys:
|
||||
|
||||
## Notes
|
||||
|
||||
- The manifest is **required for all plugins**, including local filesystem loads.
|
||||
- The manifest is **required for native OpenClaw plugins**, including local filesystem loads.
|
||||
- Runtime still loads the plugin module separately; the manifest is only for
|
||||
discovery + validation.
|
||||
- Exclusive plugin kinds are selected through `plugins.slots.*`.
|
||||
|
||||
@ -42,7 +42,7 @@ MiniMax highlights these improvements in M2.5:
|
||||
Enable the bundled OAuth plugin and authenticate:
|
||||
|
||||
```bash
|
||||
openclaw plugins enable minimax-portal-auth # skip if already loaded.
|
||||
openclaw plugins enable minimax # skip if already loaded.
|
||||
openclaw gateway restart # restart if gateway is already running
|
||||
openclaw onboard --auth-choice minimax-portal
|
||||
```
|
||||
@ -52,7 +52,7 @@ You will be prompted to select an endpoint:
|
||||
- **Global** - International users (`api.minimax.io`)
|
||||
- **CN** - Users in China (`api.minimaxi.com`)
|
||||
|
||||
See [MiniMax OAuth plugin README](https://github.com/openclaw/openclaw/tree/main/extensions/minimax-portal-auth) for details.
|
||||
See [MiniMax plugin README](https://github.com/openclaw/openclaw/tree/main/extensions/minimax) for details.
|
||||
|
||||
### MiniMax M2.5 (API key)
|
||||
|
||||
|
||||
@ -28,7 +28,7 @@ Contents (examples):
|
||||
- Config helpers: `buildChannelConfigSchema`, `setAccountEnabledInConfigSection`, `deleteAccountFromConfigSection`,
|
||||
`applyAccountNameToChannelSection`.
|
||||
- Pairing helpers: `PAIRING_APPROVED_MESSAGE`, `formatPairingApproveHint`.
|
||||
- Onboarding helpers: `promptChannelAccessConfig`, `addWildcardAllowFrom`, onboarding types.
|
||||
- Setup entry points: host-owned `setup` + `setupWizard`; avoid broad public onboarding helpers.
|
||||
- Tool param helpers: `createActionGate`, `readStringParam`, `readNumberParam`, `readReactionParams`, `jsonResult`.
|
||||
- Docs link helper: `formatDocsLink`.
|
||||
|
||||
|
||||
@ -1,161 +1,42 @@
|
||||
---
|
||||
title: "Release Checklist"
|
||||
summary: "Step-by-step release checklist for npm + macOS app"
|
||||
title: "Release Policy"
|
||||
summary: "Public release channels, version naming, and cadence"
|
||||
read_when:
|
||||
- Cutting a new npm release
|
||||
- Cutting a new macOS app release
|
||||
- Verifying metadata before publishing
|
||||
- Looking for public release channel definitions
|
||||
- Looking for version naming and cadence
|
||||
---
|
||||
|
||||
# Release Checklist (npm + macOS)
|
||||
# Release Policy
|
||||
|
||||
Use `pnpm` from the repo root with Node 24 by default. Node 22 LTS, currently `22.16+`, remains supported for compatibility. Keep the working tree clean before tagging/publishing.
|
||||
OpenClaw has three public release lanes:
|
||||
|
||||
## Operator trigger
|
||||
- stable: tagged releases that publish to npm `latest`
|
||||
- beta: prerelease tags that publish to npm `beta`
|
||||
- dev: the moving head of `main`
|
||||
|
||||
When the operator says “release”, immediately do this preflight (no extra questions unless blocked):
|
||||
|
||||
- Read this doc and `docs/platforms/mac/release.md`.
|
||||
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set (SPARKLE_PRIVATE_KEY_FILE should live in `~/.profile`).
|
||||
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
|
||||
|
||||
## Versioning
|
||||
|
||||
Current OpenClaw releases use date-based versioning.
|
||||
## Version naming
|
||||
|
||||
- Stable release version: `YYYY.M.D`
|
||||
- Git tag: `vYYYY.M.D`
|
||||
- Examples from repo history: `v2026.2.26`, `v2026.3.8`
|
||||
- Beta prerelease version: `YYYY.M.D-beta.N`
|
||||
- Git tag: `vYYYY.M.D-beta.N`
|
||||
- Examples from repo history: `v2026.2.15-beta.1`, `v2026.3.8-beta.1`
|
||||
- Fallback correction tag: `vYYYY.M.D-N`
|
||||
- Use only as a last-resort recovery tag when a published immutable release burned the original stable tag and you cannot reuse it.
|
||||
- The npm package version stays `YYYY.M.D`; the `-N` suffix is only for the git tag and GitHub release.
|
||||
- Prefer betas for normal pre-release iteration, then cut a clean stable tag once ready.
|
||||
- Use the same version string everywhere, minus the leading `v` where Git tags are not used:
|
||||
- `package.json`: `2026.3.8`
|
||||
- Git tag: `v2026.3.8`
|
||||
- GitHub release title: `openclaw 2026.3.8`
|
||||
- Do not zero-pad month or day. Use `2026.3.8`, not `2026.03.08`.
|
||||
- Stable and beta are npm dist-tags, not separate release lines:
|
||||
- `latest` = stable
|
||||
- `beta` = prerelease/testing
|
||||
- Dev is the moving head of `main`, not a normal git-tagged release.
|
||||
- The tag-triggered preview run accepts stable, beta, and fallback correction tags, and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date.
|
||||
- Do not zero-pad month or day
|
||||
- `latest` means the current stable npm release
|
||||
- `beta` means the current prerelease npm release
|
||||
- Beta releases may ship before the macOS app catches up
|
||||
|
||||
Historical note:
|
||||
## Release cadence
|
||||
|
||||
- Older tags such as `v2026.1.11-1`, `v2026.2.6-3`, and `v2.0.0-beta2` exist in repo history.
|
||||
- Treat correction tags as a fallback-only escape hatch. New releases should still use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta.
|
||||
- Releases move beta-first
|
||||
- Stable follows only after the latest beta is validated
|
||||
- Detailed release procedure, approvals, credentials, and recovery notes are
|
||||
maintainer-only
|
||||
|
||||
1. **Version & metadata**
|
||||
## Public references
|
||||
|
||||
- [ ] Bump `package.json` version (e.g., `2026.1.29`).
|
||||
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
|
||||
- [ ] Update CLI/version strings in [`src/version.ts`](https://github.com/openclaw/openclaw/blob/main/src/version.ts) and the Baileys user agent in [`src/web/session.ts`](https://github.com/openclaw/openclaw/blob/main/src/web/session.ts).
|
||||
- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`openclaw.mjs`](https://github.com/openclaw/openclaw/blob/main/openclaw.mjs) for `openclaw`.
|
||||
- [ ] If dependencies changed, run `pnpm install` so `pnpm-lock.yaml` is current.
|
||||
- [`.github/workflows/openclaw-npm-release.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-npm-release.yml)
|
||||
- [`scripts/openclaw-npm-release-check.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/openclaw-npm-release-check.ts)
|
||||
|
||||
2. **Build & artifacts**
|
||||
|
||||
- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/openclaw/openclaw/blob/main/src/canvas-host/a2ui/a2ui.bundle.js).
|
||||
- [ ] `pnpm run build` (regenerates `dist/`).
|
||||
- [ ] Verify npm package `files` includes all required `dist/*` folders (notably `dist/node-host/**` and `dist/acp/**` for headless node + ACP CLI).
|
||||
- [ ] Confirm `dist/build-info.json` exists and includes the expected `commit` hash (CLI banner uses this for npm installs).
|
||||
- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it).
|
||||
|
||||
3. **Changelog & docs**
|
||||
|
||||
- [ ] Update `CHANGELOG.md` with user-facing highlights (create the file if missing); keep entries strictly descending by version.
|
||||
- [ ] Ensure README examples/flags match current CLI behavior (notably new commands or options).
|
||||
|
||||
4. **Validation**
|
||||
|
||||
- [ ] `pnpm build`
|
||||
- [ ] `pnpm check`
|
||||
- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output)
|
||||
- [ ] `pnpm release:check` (verifies npm pack contents)
|
||||
- [ ] If `pnpm config:docs:check` fails as part of release validation and the config-surface change is intentional, run `pnpm config:docs:gen`, review `docs/.generated/config-baseline.json` and `docs/.generated/config-baseline.jsonl`, commit the updated baselines, then rerun `pnpm release:check`.
|
||||
- [ ] `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` (Docker install smoke test, fast path; required before release)
|
||||
- If the immediate previous npm release is known broken, set `OPENCLAW_INSTALL_SMOKE_PREVIOUS=<last-good-version>` or `OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1` for the preinstall step.
|
||||
- [ ] (Optional) Full installer smoke (adds non-root + CLI coverage): `pnpm test:install:smoke`
|
||||
- [ ] (Optional) Installer E2E (Docker, runs `curl -fsSL https://openclaw.ai/install.sh | bash`, onboards, then runs real tool calls):
|
||||
- `pnpm test:install:e2e:openai` (requires `OPENAI_API_KEY`)
|
||||
- `pnpm test:install:e2e:anthropic` (requires `ANTHROPIC_API_KEY`)
|
||||
- `pnpm test:install:e2e` (requires both keys; runs both providers)
|
||||
- [ ] (Optional) Spot-check the web gateway if your changes affect send/receive paths.
|
||||
|
||||
5. **macOS app (Sparkle)**
|
||||
|
||||
- [ ] Build + sign the macOS app, then zip it for distribution.
|
||||
- [ ] Generate the Sparkle appcast (HTML notes via [`scripts/make_appcast.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/make_appcast.sh)) and update `appcast.xml`.
|
||||
- [ ] Keep the app zip (and optional dSYM zip) ready to attach to the GitHub release.
|
||||
- [ ] Follow [macOS release](/platforms/mac/release) for the exact commands and required env vars.
|
||||
- `APP_BUILD` must be numeric + monotonic (no `-beta`) so Sparkle compares versions correctly.
|
||||
- If notarizing, use the `openclaw-notary` keychain profile created from App Store Connect API env vars (see [macOS release](/platforms/mac/release)).
|
||||
|
||||
6. **Publish (npm)**
|
||||
|
||||
- [ ] Confirm git status is clean; commit and push as needed.
|
||||
- [ ] Confirm npm trusted publishing is configured for the `openclaw` package.
|
||||
- [ ] Do not rely on an `NPM_TOKEN` secret for this workflow; the publish job uses GitHub OIDC trusted publishing.
|
||||
- [ ] Push the matching git tag to trigger the preview run in `.github/workflows/openclaw-npm-release.yml`.
|
||||
- [ ] Run `OpenClaw NPM Release` manually with the same tag to publish after `npm-release` environment approval.
|
||||
- Stable tags publish to npm `latest`.
|
||||
- Beta tags publish to npm `beta`.
|
||||
- Fallback correction tags like `v2026.3.13-1` map to npm version `2026.3.13`.
|
||||
- Both the preview run and the manual publish run reject tags that do not map back to `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date.
|
||||
- If `openclaw@YYYY.M.D` is already published, a fallback correction tag is still useful for GitHub release and Docker recovery, but npm publish will not republish that version.
|
||||
- [ ] Verify the registry: `npm view openclaw version`, `npm view openclaw dist-tags`, and `npx -y openclaw@X.Y.Z --version` (or `--help`).
|
||||
|
||||
### Troubleshooting (notes from 2.0.0-beta2 release)
|
||||
|
||||
- **npm pack/publish hangs or produces huge tarball**: the macOS app bundle in `dist/OpenClaw.app` (and release zips) get swept into the package. Fix by whitelisting publish contents via `package.json` `files` (include dist subdirs, docs, skills; exclude app bundles). Confirm with `npm pack --dry-run` that `dist/OpenClaw.app` is not listed.
|
||||
- **npm auth web loop for dist-tags**: use legacy auth to get an OTP prompt:
|
||||
- `NPM_CONFIG_AUTH_TYPE=legacy npm dist-tag add openclaw@X.Y.Z latest`
|
||||
- **`npx` verification fails with `ECOMPROMISED: Lock compromised`**: retry with a fresh cache:
|
||||
- `NPM_CONFIG_CACHE=/tmp/npm-cache-$(date +%s) npx -y openclaw@X.Y.Z --version`
|
||||
- **Tag needs recovery after a late fix**: if the original stable tag is tied to an immutable GitHub release, mint a fallback correction tag like `vX.Y.Z-1` instead of trying to force-update `vX.Y.Z`.
|
||||
- Keep the npm package version at `X.Y.Z`; the correction suffix is for the git tag and GitHub release only.
|
||||
- Use this only as a last resort. For normal iteration, prefer beta tags and then cut a clean stable release.
|
||||
|
||||
7. **GitHub release + appcast**
|
||||
|
||||
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
|
||||
- Pushing the tag also triggers the npm release workflow.
|
||||
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `openclaw X.Y.Z`** (not just the tag); body should include the **full** changelog section for that version (Highlights + Changes + Fixes), inline (no bare links), and **must not repeat the title inside the body**.
|
||||
- [ ] Attach artifacts: `npm pack` tarball (optional), `OpenClaw-X.Y.Z.zip`, and `OpenClaw-X.Y.Z.dSYM.zip` (if generated).
|
||||
- [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main).
|
||||
- [ ] From a clean temp directory (no `package.json`), run `npx -y openclaw@X.Y.Z send --help` to confirm install/CLI entrypoints work.
|
||||
- [ ] Announce/share release notes.
|
||||
|
||||
## Plugin publish scope (npm)
|
||||
|
||||
We only publish **existing npm plugins** under the `@openclaw/*` scope. Bundled
|
||||
plugins that are not on npm stay **disk-tree only** (still shipped in
|
||||
`extensions/**`).
|
||||
|
||||
Process to derive the list:
|
||||
|
||||
1. `npm search @openclaw --json` and capture the package names.
|
||||
2. Compare with `extensions/*/package.json` names.
|
||||
3. Publish only the **intersection** (already on npm).
|
||||
|
||||
Current npm plugin list (update as needed):
|
||||
|
||||
- @openclaw/bluebubbles
|
||||
- @openclaw/diagnostics-otel
|
||||
- @openclaw/discord
|
||||
- @openclaw/feishu
|
||||
- @openclaw/lobster
|
||||
- @openclaw/matrix
|
||||
- @openclaw/msteams
|
||||
- @openclaw/nextcloud-talk
|
||||
- @openclaw/nostr
|
||||
- @openclaw/voice-call
|
||||
- @openclaw/zalo
|
||||
- @openclaw/zalouser
|
||||
|
||||
Release notes must also call out **new optional bundled plugins** that are **not
|
||||
on by default** (example: `tlon`).
|
||||
Maintainers use the private release docs in
|
||||
[`openclaw/maintainers/release/README.md`](https://github.com/openclaw/maintainers/blob/main/release/README.md)
|
||||
for the actual runbook.
|
||||
|
||||
@ -157,7 +157,6 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
||||
- [macOS permissions](/platforms/mac/permissions)
|
||||
- [macOS remote](/platforms/mac/remote)
|
||||
- [macOS signing](/platforms/mac/signing)
|
||||
- [macOS release](/platforms/mac/release)
|
||||
- [macOS gateway (launchd)](/platforms/mac/bundled-gateway)
|
||||
- [macOS XPC](/platforms/mac/xpc)
|
||||
- [macOS skills](/platforms/mac/skills)
|
||||
@ -190,5 +189,5 @@ Use these hubs to discover every page, including deep dives and reference docs t
|
||||
## Testing + release
|
||||
|
||||
- [Testing](/reference/test)
|
||||
- [Release checklist](/reference/RELEASING)
|
||||
- [Release policy](/reference/RELEASING)
|
||||
- [Device models](/reference/device-models)
|
||||
|
||||
@ -96,7 +96,8 @@ pnpm install
|
||||
pnpm gateway:watch
|
||||
```
|
||||
|
||||
`gateway:watch` runs the gateway in watch mode and reloads on TypeScript changes.
|
||||
`gateway:watch` runs the gateway in watch mode and reloads on relevant source,
|
||||
config, and bundled-plugin metadata changes.
|
||||
|
||||
### 2) Point the macOS app at your running Gateway
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ summary: "OpenClaw plugins/extensions: discovery, config, and safety"
|
||||
read_when:
|
||||
- Adding or modifying plugins/extensions
|
||||
- Documenting plugin install or load rules
|
||||
- Working with Codex/Claude-compatible plugin bundles
|
||||
title: "Plugins"
|
||||
---
|
||||
|
||||
@ -10,8 +11,13 @@ title: "Plugins"
|
||||
|
||||
## Quick start (new to plugins?)
|
||||
|
||||
A plugin is just a **small code module** that extends OpenClaw with extra
|
||||
features (commands, tools, and Gateway RPC).
|
||||
A plugin is either:
|
||||
|
||||
- a native **OpenClaw plugin** (`openclaw.plugin.json` + runtime module), or
|
||||
- a compatible **bundle** (`.codex-plugin/plugin.json` or `.claude-plugin/plugin.json`)
|
||||
|
||||
Both show up under `openclaw plugins`, but only native OpenClaw plugins execute
|
||||
runtime code in-process.
|
||||
|
||||
Most of the time, you’ll use plugins when you want a feature that’s not built
|
||||
into core OpenClaw yet (or you want to keep optional features out of your main
|
||||
@ -42,6 +48,14 @@ prerelease tag such as `@beta`/`@rc` or an exact prerelease version.
|
||||
|
||||
See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
||||
Looking for third-party listings? See [Community plugins](/plugins/community).
|
||||
Need the bundle compatibility details? See [Plugin bundles](/plugins/bundles).
|
||||
|
||||
For compatible bundles, install from a local directory or archive:
|
||||
|
||||
```bash
|
||||
openclaw plugins install ./my-bundle
|
||||
openclaw plugins install ./my-bundle.tgz
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
@ -49,14 +63,15 @@ OpenClaw's plugin system has four layers:
|
||||
|
||||
1. **Manifest + discovery**
|
||||
OpenClaw finds candidate plugins from configured paths, workspace roots,
|
||||
global extension roots, and bundled extensions. Discovery reads
|
||||
`openclaw.plugin.json` plus package metadata first.
|
||||
global extension roots, and bundled extensions. Discovery reads native
|
||||
`openclaw.plugin.json` manifests plus supported bundle manifests first.
|
||||
2. **Enablement + validation**
|
||||
Core decides whether a discovered plugin is enabled, disabled, blocked, or
|
||||
selected for an exclusive slot such as memory.
|
||||
3. **Runtime loading**
|
||||
Enabled plugins are loaded in-process via jiti and register capabilities into
|
||||
a central registry.
|
||||
Native OpenClaw plugins are loaded in-process via jiti and register
|
||||
capabilities into a central registry. Compatible bundles are normalized into
|
||||
registry records without importing runtime code.
|
||||
4. **Surface consumption**
|
||||
The rest of OpenClaw reads the registry to expose tools, channels, provider
|
||||
setup, hooks, HTTP routes, CLI commands, and services.
|
||||
@ -65,22 +80,68 @@ The important design boundary:
|
||||
|
||||
- discovery + config validation should work from **manifest/schema metadata**
|
||||
without executing plugin code
|
||||
- runtime behavior comes from the plugin module's `register(api)` path
|
||||
- native runtime behavior comes from the plugin module's `register(api)` path
|
||||
|
||||
That split lets OpenClaw validate config, explain missing/disabled plugins, and
|
||||
build UI/schema hints before the full runtime is active.
|
||||
|
||||
## Compatible bundles
|
||||
|
||||
OpenClaw also recognizes two compatible external bundle layouts:
|
||||
|
||||
- Codex-style bundles: `.codex-plugin/plugin.json`
|
||||
- Claude-style bundles: `.claude-plugin/plugin.json` or the default Claude
|
||||
component layout without a manifest
|
||||
- Cursor-style bundles: `.cursor-plugin/plugin.json`
|
||||
|
||||
They are shown in the plugin list as `format=bundle`, with a subtype of
|
||||
`codex` or `claude` in verbose/info output.
|
||||
|
||||
See [Plugin bundles](/plugins/bundles) for the exact detection rules, mapping
|
||||
behavior, and current support matrix.
|
||||
|
||||
Today, OpenClaw treats these as **capability packs**, not native runtime
|
||||
plugins:
|
||||
|
||||
- supported now: bundled `skills`
|
||||
- supported now: Claude `commands/` markdown roots, mapped into the normal
|
||||
OpenClaw skill loader
|
||||
- supported now: Claude bundle `settings.json` defaults for embedded Pi agent
|
||||
settings (with shell override keys sanitized)
|
||||
- supported now: Cursor `.cursor/commands/*.md` roots, mapped into the normal
|
||||
OpenClaw skill loader
|
||||
- supported now: Codex bundle hook directories that use the OpenClaw hook-pack
|
||||
layout (`HOOK.md` + `handler.ts`/`handler.js`)
|
||||
- detected but not wired yet: other declared bundle capabilities such as
|
||||
agents, Claude hook automation, Cursor rules/hooks/MCP metadata, MCP/app/LSP
|
||||
metadata, output styles
|
||||
|
||||
That means bundle install/discovery/list/info/enablement all work, and bundle
|
||||
skills, Claude command-skills, Claude bundle settings defaults, and compatible
|
||||
Codex hook directories load when the bundle is enabled, but bundle runtime code
|
||||
is not executed in-process.
|
||||
|
||||
Bundle hook support is limited to the normal OpenClaw hook directory format
|
||||
(`HOOK.md` plus `handler.ts`/`handler.js` under the declared hook roots).
|
||||
Vendor-specific shell/JSON hook runtimes, including Claude `hooks.json`, are
|
||||
only detected today and are not executed directly.
|
||||
|
||||
## Execution model
|
||||
|
||||
Plugins run **in-process** with the Gateway. They are not sandboxed. A loaded
|
||||
plugin has the same process-level trust boundary as core code.
|
||||
Native OpenClaw plugins run **in-process** with the Gateway. They are not
|
||||
sandboxed. A loaded native plugin has the same process-level trust boundary as
|
||||
core code.
|
||||
|
||||
Implications:
|
||||
|
||||
- a plugin can register tools, network handlers, hooks, and services
|
||||
- a plugin bug can crash or destabilize the gateway
|
||||
- a malicious plugin is equivalent to arbitrary code execution inside the
|
||||
OpenClaw process
|
||||
- a native plugin can register tools, network handlers, hooks, and services
|
||||
- a native plugin bug can crash or destabilize the gateway
|
||||
- a malicious native plugin is equivalent to arbitrary code execution inside
|
||||
the OpenClaw process
|
||||
|
||||
Compatible bundles are safer by default because OpenClaw currently treats them
|
||||
as metadata/content packs. In current releases, that mostly means bundled
|
||||
skills.
|
||||
|
||||
Use allowlists and explicit install/load paths for non-bundled plugins. Treat
|
||||
workspace plugins as development-time code, not production defaults.
|
||||
@ -103,16 +164,39 @@ Important trust note:
|
||||
- [Nostr](/channels/nostr) — `@openclaw/nostr`
|
||||
- [Zalo](/channels/zalo) — `@openclaw/zalo`
|
||||
- [Microsoft Teams](/channels/msteams) — `@openclaw/msteams`
|
||||
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
|
||||
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
|
||||
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
|
||||
- Anthropic provider runtime — bundled as `anthropic` (enabled by default)
|
||||
- BytePlus provider catalog — bundled as `byteplus` (enabled by default)
|
||||
- Cloudflare AI Gateway provider catalog — bundled as `cloudflare-ai-gateway` (enabled by default)
|
||||
- Google web search + Gemini CLI OAuth — bundled as `google` (web search auto-loads it; provider auth stays opt-in)
|
||||
- GitHub Copilot provider runtime — bundled as `github-copilot` (enabled by default)
|
||||
- Hugging Face provider catalog — bundled as `huggingface` (enabled by default)
|
||||
- Kilo Gateway provider runtime — bundled as `kilocode` (enabled by default)
|
||||
- Kimi Coding provider catalog — bundled as `kimi-coding` (enabled by default)
|
||||
- MiniMax provider catalog + usage + OAuth — bundled as `minimax` (enabled by default; owns `minimax` and `minimax-portal`)
|
||||
- Mistral provider capabilities — bundled as `mistral` (enabled by default)
|
||||
- Model Studio provider catalog — bundled as `modelstudio` (enabled by default)
|
||||
- Moonshot provider runtime — bundled as `moonshot` (enabled by default)
|
||||
- NVIDIA provider catalog — bundled as `nvidia` (enabled by default)
|
||||
- OpenAI provider runtime — bundled as `openai` (enabled by default; owns both `openai` and `openai-codex`)
|
||||
- OpenCode Go provider capabilities — bundled as `opencode-go` (enabled by default)
|
||||
- OpenCode Zen provider capabilities — bundled as `opencode` (enabled by default)
|
||||
- OpenRouter provider runtime — bundled as `openrouter` (enabled by default)
|
||||
- Qianfan provider catalog — bundled as `qianfan` (enabled by default)
|
||||
- Qwen OAuth (provider auth + catalog) — bundled as `qwen-portal-auth` (enabled by default)
|
||||
- Synthetic provider catalog — bundled as `synthetic` (enabled by default)
|
||||
- Together provider catalog — bundled as `together` (enabled by default)
|
||||
- Venice provider catalog — bundled as `venice` (enabled by default)
|
||||
- Vercel AI Gateway provider catalog — bundled as `vercel-ai-gateway` (enabled by default)
|
||||
- Volcengine provider catalog — bundled as `volcengine` (enabled by default)
|
||||
- Xiaomi provider catalog + usage — bundled as `xiaomi` (enabled by default)
|
||||
- Z.AI provider runtime — bundled as `zai` (enabled by default)
|
||||
- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
|
||||
|
||||
OpenClaw plugins are **TypeScript modules** loaded at runtime via jiti. **Config
|
||||
validation does not execute plugin code**; it uses the plugin manifest and JSON
|
||||
Schema instead. See [Plugin manifest](/plugins/manifest).
|
||||
Native OpenClaw plugins are **TypeScript modules** loaded at runtime via jiti.
|
||||
**Config validation does not execute plugin code**; it uses the plugin manifest
|
||||
and JSON Schema instead. See [Plugin manifest](/plugins/manifest).
|
||||
|
||||
Plugins can register:
|
||||
Native OpenClaw plugins can register:
|
||||
|
||||
- Gateway RPC methods
|
||||
- Gateway HTTP routes
|
||||
@ -120,25 +204,222 @@ Plugins can register:
|
||||
- CLI commands
|
||||
- Background services
|
||||
- Context engines
|
||||
- Provider auth flows and model catalogs
|
||||
- Provider runtime hooks for dynamic model ids, transport normalization, capability metadata, stream wrapping, cache TTL policy, missing-auth hints, built-in model suppression, catalog augmentation, runtime auth exchange, and usage/billing auth + snapshot resolution
|
||||
- Optional config validation
|
||||
- **Skills** (by listing `skills` directories in the plugin manifest)
|
||||
- **Auto-reply commands** (execute without invoking the AI agent)
|
||||
|
||||
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||
Native OpenClaw plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
||||
|
||||
## Provider runtime hooks
|
||||
|
||||
Provider plugins now have two layers:
|
||||
|
||||
- config-time hooks: `catalog` / legacy `discovery`
|
||||
- runtime hooks: `resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`, `capabilities`, `prepareExtraParams`, `wrapStreamFn`, `isCacheTtlEligible`, `buildMissingAuthMessage`, `suppressBuiltInModel`, `augmentModelCatalog`, `prepareRuntimeAuth`, `resolveUsageAuth`, `fetchUsageSnapshot`
|
||||
|
||||
OpenClaw still owns the generic agent loop, failover, transcript handling, and
|
||||
tool policy. These hooks are the seam for provider-specific behavior without
|
||||
needing a whole custom inference transport.
|
||||
|
||||
### Hook order
|
||||
|
||||
For model/provider plugins, OpenClaw uses hooks in this rough order:
|
||||
|
||||
1. `catalog`
|
||||
Publish provider config into `models.providers` during `models.json`
|
||||
generation.
|
||||
2. built-in/discovered model lookup
|
||||
OpenClaw tries the normal registry/catalog path first.
|
||||
3. `resolveDynamicModel`
|
||||
Sync fallback for provider-owned model ids that are not in the local
|
||||
registry yet.
|
||||
4. `prepareDynamicModel`
|
||||
Async warm-up only on async model resolution paths, then
|
||||
`resolveDynamicModel` runs again.
|
||||
5. `normalizeResolvedModel`
|
||||
Final rewrite before the embedded runner uses the resolved model.
|
||||
6. `capabilities`
|
||||
Provider-owned transcript/tooling metadata used by shared core logic.
|
||||
7. `prepareExtraParams`
|
||||
Provider-owned request-param normalization before generic stream option wrappers.
|
||||
8. `wrapStreamFn`
|
||||
Provider-owned stream wrapper after generic wrappers are applied.
|
||||
9. `isCacheTtlEligible`
|
||||
Provider-owned prompt-cache policy for proxy/backhaul providers.
|
||||
10. `buildMissingAuthMessage`
|
||||
Provider-owned replacement for the generic missing-auth recovery message.
|
||||
11. `suppressBuiltInModel`
|
||||
Provider-owned stale upstream model suppression plus optional user-facing
|
||||
error hint.
|
||||
12. `augmentModelCatalog`
|
||||
Provider-owned synthetic/final catalog rows appended after discovery.
|
||||
13. `prepareRuntimeAuth`
|
||||
Exchanges a configured credential into the actual runtime token/key just
|
||||
before inference.
|
||||
14. `resolveUsageAuth`
|
||||
Resolves usage/billing credentials for `/usage` and related status
|
||||
surfaces.
|
||||
15. `fetchUsageSnapshot`
|
||||
Fetches and normalizes provider-specific usage/quota snapshots after auth
|
||||
is resolved.
|
||||
|
||||
### Which hook to use
|
||||
|
||||
- `catalog`: publish provider config and model catalogs into `models.providers`
|
||||
- `resolveDynamicModel`: handle pass-through or forward-compat model ids that are not in the local registry yet
|
||||
- `prepareDynamicModel`: async warm-up before retrying dynamic resolution (for example refresh provider metadata cache)
|
||||
- `normalizeResolvedModel`: rewrite a resolved model's transport/base URL/compat before inference
|
||||
- `capabilities`: publish provider-family and transcript/tooling quirks without hardcoding provider ids in core
|
||||
- `prepareExtraParams`: set provider defaults or normalize provider-specific per-model params before generic stream wrapping
|
||||
- `wrapStreamFn`: add provider-specific headers/payload/model compat patches while still using the normal `pi-ai` execution path
|
||||
- `isCacheTtlEligible`: decide whether provider/model pairs should use cache TTL metadata
|
||||
- `buildMissingAuthMessage`: replace the generic auth-store error with a provider-specific recovery hint
|
||||
- `suppressBuiltInModel`: hide stale upstream rows and optionally return a provider-owned error for direct resolution failures
|
||||
- `augmentModelCatalog`: append synthetic/final catalog rows after discovery and config merging
|
||||
- `prepareRuntimeAuth`: exchange a configured credential into the actual short-lived runtime token/key used for requests
|
||||
- `resolveUsageAuth`: resolve provider-owned credentials for usage/billing endpoints without hardcoding token parsing in core
|
||||
- `fetchUsageSnapshot`: own provider-specific usage endpoint fetch/parsing while core keeps summary fan-out and formatting
|
||||
|
||||
Rule of thumb:
|
||||
|
||||
- provider owns a catalog or base URL defaults: use `catalog`
|
||||
- provider accepts arbitrary upstream model ids: use `resolveDynamicModel`
|
||||
- provider needs network metadata before resolving unknown ids: add `prepareDynamicModel`
|
||||
- provider needs transport rewrites but still uses a core transport: use `normalizeResolvedModel`
|
||||
- provider needs transcript/provider-family quirks: use `capabilities`
|
||||
- provider needs default request params or per-provider param cleanup: use `prepareExtraParams`
|
||||
- provider needs request headers/body/model compat wrappers without a custom transport: use `wrapStreamFn`
|
||||
- provider needs proxy-specific cache TTL gating: use `isCacheTtlEligible`
|
||||
- provider needs a provider-specific missing-auth recovery hint: use `buildMissingAuthMessage`
|
||||
- provider needs to hide stale upstream rows or replace them with a vendor hint: use `suppressBuiltInModel`
|
||||
- provider needs synthetic forward-compat rows in `models list` and pickers: use `augmentModelCatalog`
|
||||
- provider needs a token exchange or short-lived request credential: use `prepareRuntimeAuth`
|
||||
- provider needs custom usage/quota token parsing or a different usage credential: use `resolveUsageAuth`
|
||||
- provider needs a provider-specific usage endpoint or payload parser: use `fetchUsageSnapshot`
|
||||
|
||||
If the provider needs a fully custom wire protocol or custom request executor,
|
||||
that is a different class of extension. These hooks are for provider behavior
|
||||
that still runs on OpenClaw's normal inference loop.
|
||||
|
||||
### Provider Example
|
||||
|
||||
```ts
|
||||
api.registerProvider({
|
||||
id: "example-proxy",
|
||||
label: "Example Proxy",
|
||||
auth: [],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => {
|
||||
const apiKey = ctx.resolveProviderApiKey("example-proxy").apiKey;
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: {
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
apiKey,
|
||||
api: "openai-completions",
|
||||
models: [{ id: "auto", name: "Auto" }],
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
resolveDynamicModel: (ctx) => ({
|
||||
id: ctx.modelId,
|
||||
name: ctx.modelId,
|
||||
provider: "example-proxy",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
}),
|
||||
prepareRuntimeAuth: async (ctx) => {
|
||||
const exchanged = await exchangeToken(ctx.apiKey);
|
||||
return {
|
||||
apiKey: exchanged.token,
|
||||
baseUrl: exchanged.baseUrl,
|
||||
expiresAt: exchanged.expiresAt,
|
||||
};
|
||||
},
|
||||
resolveUsageAuth: async (ctx) => {
|
||||
const auth = await ctx.resolveOAuthToken();
|
||||
return auth ? { token: auth.token } : null;
|
||||
},
|
||||
fetchUsageSnapshot: async (ctx) => {
|
||||
return await fetchExampleProxyUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Built-in examples
|
||||
|
||||
- Anthropic uses `resolveDynamicModel`, `capabilities`, `resolveUsageAuth`,
|
||||
`fetchUsageSnapshot`, and `isCacheTtlEligible` because it owns Claude 4.6
|
||||
forward-compat, provider-family hints, usage endpoint integration, and
|
||||
prompt-cache eligibility.
|
||||
- OpenAI uses `resolveDynamicModel`, `normalizeResolvedModel`, and
|
||||
`capabilities` plus `buildMissingAuthMessage`, `suppressBuiltInModel`, and
|
||||
`augmentModelCatalog` because it owns GPT-5.4 forward-compat, the direct
|
||||
OpenAI `openai-completions` -> `openai-responses` normalization, Codex-aware
|
||||
auth hints, Spark suppression, and synthetic OpenAI list rows.
|
||||
- OpenRouter uses `catalog` plus `resolveDynamicModel` and
|
||||
`prepareDynamicModel` because the provider is pass-through and may expose new
|
||||
model ids before OpenClaw's static catalog updates.
|
||||
- GitHub Copilot uses `catalog`, `resolveDynamicModel`, and
|
||||
`capabilities` plus `prepareRuntimeAuth` and `fetchUsageSnapshot` because it
|
||||
needs model fallback behavior, Claude transcript quirks, a GitHub token ->
|
||||
Copilot token exchange, and a provider-owned usage endpoint.
|
||||
- OpenAI Codex uses `catalog`, `resolveDynamicModel`,
|
||||
`normalizeResolvedModel`, and `augmentModelCatalog` plus
|
||||
`prepareExtraParams`, `resolveUsageAuth`, and `fetchUsageSnapshot` because it
|
||||
still runs on core OpenAI transports but owns its transport/base URL
|
||||
normalization, default transport choice, synthetic Codex catalog rows, and
|
||||
ChatGPT usage endpoint integration.
|
||||
- Gemini CLI OAuth uses `resolveDynamicModel`, `resolveUsageAuth`, and
|
||||
`fetchUsageSnapshot` because it owns Gemini 3.1 forward-compat fallback plus
|
||||
the token parsing and quota endpoint wiring needed by `/usage`.
|
||||
- OpenRouter uses `capabilities`, `wrapStreamFn`, and `isCacheTtlEligible`
|
||||
to keep provider-specific request headers, routing metadata, reasoning
|
||||
patches, and prompt-cache policy out of core.
|
||||
- Moonshot uses `catalog` plus `wrapStreamFn` because it still uses the shared
|
||||
OpenAI transport but needs provider-owned thinking payload normalization.
|
||||
- Kilocode uses `catalog`, `capabilities`, `wrapStreamFn`, and
|
||||
`isCacheTtlEligible` because it needs provider-owned request headers,
|
||||
reasoning payload normalization, Gemini transcript hints, and Anthropic
|
||||
cache-TTL gating.
|
||||
- Z.AI uses `resolveDynamicModel`, `prepareExtraParams`, `wrapStreamFn`,
|
||||
`isCacheTtlEligible`, `resolveUsageAuth`, and `fetchUsageSnapshot` because it
|
||||
owns GLM-5 fallback, `tool_stream` defaults, and both usage auth + quota
|
||||
fetching.
|
||||
- Mistral, OpenCode Zen, and OpenCode Go use `capabilities` only to keep
|
||||
transcript/tooling quirks out of core.
|
||||
- Catalog-only bundled providers such as `byteplus`, `cloudflare-ai-gateway`,
|
||||
`huggingface`, `kimi-coding`, `minimax-portal`, `modelstudio`, `nvidia`,
|
||||
`qianfan`, `qwen-portal`, `synthetic`, `together`, `venice`,
|
||||
`vercel-ai-gateway`, and `volcengine` use `catalog` only.
|
||||
- MiniMax and Xiaomi use `catalog` plus usage hooks because their `/usage`
|
||||
behavior is plugin-owned even though inference still runs through the shared
|
||||
transports.
|
||||
|
||||
## Load pipeline
|
||||
|
||||
At startup, OpenClaw does roughly this:
|
||||
|
||||
1. discover candidate plugin roots
|
||||
2. read `openclaw.plugin.json` and package metadata
|
||||
2. read native or compatible bundle manifests and package metadata
|
||||
3. reject unsafe candidates
|
||||
4. normalize plugin config (`plugins.enabled`, `allow`, `deny`, `entries`,
|
||||
`slots`, `load.paths`)
|
||||
5. decide enablement for each candidate
|
||||
6. load enabled modules via jiti
|
||||
7. call `register(api)` and collect registrations into the plugin registry
|
||||
6. load enabled native modules via jiti
|
||||
7. call native `register(api)` hooks and collect registrations into the plugin registry
|
||||
8. expose the registry to commands/runtime surfaces
|
||||
|
||||
The safety gates happen **before** runtime execution. Candidates are blocked
|
||||
@ -150,13 +431,13 @@ ownership looks suspicious for non-bundled plugins.
|
||||
The manifest is the control-plane source of truth. OpenClaw uses it to:
|
||||
|
||||
- identify the plugin
|
||||
- discover declared channels/skills/config schema
|
||||
- discover declared channels/skills/config schema or bundle capabilities
|
||||
- validate `plugins.entries.<id>.config`
|
||||
- augment Control UI labels/placeholders
|
||||
- show install/catalog metadata
|
||||
|
||||
The runtime module is the data-plane part. It registers actual behavior such as
|
||||
hooks, tools, commands, or provider flows.
|
||||
For native plugins, the runtime module is the data-plane part. It registers
|
||||
actual behavior such as hooks, tools, commands, or provider flows.
|
||||
|
||||
### What the loader caches
|
||||
|
||||
@ -253,8 +534,7 @@ authoring plugins:
|
||||
`openclaw/plugin-sdk/acpx`, `openclaw/plugin-sdk/bluebubbles`,
|
||||
`openclaw/plugin-sdk/copilot-proxy`, `openclaw/plugin-sdk/device-pair`,
|
||||
`openclaw/plugin-sdk/diagnostics-otel`, `openclaw/plugin-sdk/diffs`,
|
||||
`openclaw/plugin-sdk/feishu`,
|
||||
`openclaw/plugin-sdk/google-gemini-cli-auth`, `openclaw/plugin-sdk/googlechat`,
|
||||
`openclaw/plugin-sdk/feishu`, `openclaw/plugin-sdk/googlechat`,
|
||||
`openclaw/plugin-sdk/irc`, `openclaw/plugin-sdk/llm-task`,
|
||||
`openclaw/plugin-sdk/lobster`, `openclaw/plugin-sdk/matrix`,
|
||||
`openclaw/plugin-sdk/mattermost`, `openclaw/plugin-sdk/memory-core`,
|
||||
@ -268,6 +548,36 @@ authoring plugins:
|
||||
`openclaw/plugin-sdk/twitch`, `openclaw/plugin-sdk/voice-call`,
|
||||
`openclaw/plugin-sdk/zalo`, and `openclaw/plugin-sdk/zalouser`.
|
||||
|
||||
## Provider catalogs
|
||||
|
||||
Provider plugins can define model catalogs for inference with
|
||||
`registerProvider({ catalog: { run(...) { ... } } })`.
|
||||
|
||||
`catalog.run(...)` returns the same shape OpenClaw writes into
|
||||
`models.providers`:
|
||||
|
||||
- `{ provider }` for one provider entry
|
||||
- `{ providers }` for multiple provider entries
|
||||
|
||||
Use `catalog` when the plugin owns provider-specific model ids, base URL
|
||||
defaults, or auth-gated model metadata.
|
||||
|
||||
`catalog.order` controls when a plugin's catalog merges relative to OpenClaw's
|
||||
built-in implicit providers:
|
||||
|
||||
- `simple`: plain API-key or env-driven providers
|
||||
- `profile`: providers that appear when auth profiles exist
|
||||
- `paired`: providers that synthesize multiple related provider entries
|
||||
- `late`: last pass, after other implicit providers
|
||||
|
||||
Later providers win on key collision, so plugins can intentionally override a
|
||||
built-in provider entry with the same provider id.
|
||||
|
||||
Compatibility:
|
||||
|
||||
- `discovery` still works as a legacy alias
|
||||
- if both `catalog` and `discovery` are registered, OpenClaw uses `catalog`
|
||||
|
||||
Compatibility note:
|
||||
|
||||
- `openclaw/plugin-sdk` remains supported for existing external plugins.
|
||||
@ -334,18 +644,44 @@ OpenClaw scans, in order:
|
||||
- `~/.openclaw/extensions/*.ts`
|
||||
- `~/.openclaw/extensions/*/index.ts`
|
||||
|
||||
4. Bundled extensions (shipped with OpenClaw, mostly disabled by default)
|
||||
4. Bundled extensions (shipped with OpenClaw; mixed default-on/default-off)
|
||||
|
||||
- `<openclaw>/extensions/*`
|
||||
|
||||
Most bundled plugins must be enabled explicitly via
|
||||
`plugins.entries.<id>.enabled` or `openclaw plugins enable <id>`.
|
||||
Many bundled provider plugins are enabled by default so model catalogs/runtime
|
||||
hooks stay available without extra setup. Others still require explicit
|
||||
enablement via `plugins.entries.<id>.enabled` or
|
||||
`openclaw plugins enable <id>`.
|
||||
|
||||
Default-on bundled plugin exceptions:
|
||||
Default-on bundled plugin examples:
|
||||
|
||||
- `byteplus`
|
||||
- `cloudflare-ai-gateway`
|
||||
- `device-pair`
|
||||
- `github-copilot`
|
||||
- `huggingface`
|
||||
- `kilocode`
|
||||
- `kimi-coding`
|
||||
- `minimax`
|
||||
- `minimax`
|
||||
- `modelstudio`
|
||||
- `moonshot`
|
||||
- `nvidia`
|
||||
- `ollama`
|
||||
- `openai`
|
||||
- `openrouter`
|
||||
- `phone-control`
|
||||
- `qianfan`
|
||||
- `qwen-portal-auth`
|
||||
- `sglang`
|
||||
- `synthetic`
|
||||
- `talk-voice`
|
||||
- `together`
|
||||
- `venice`
|
||||
- `vercel-ai-gateway`
|
||||
- `vllm`
|
||||
- `volcengine`
|
||||
- `xiaomi`
|
||||
- active memory slot plugin (default slot: `memory-core`)
|
||||
|
||||
Installed plugins are enabled by default, but can be disabled the same way.
|
||||
@ -363,9 +699,16 @@ Hardening notes:
|
||||
- path ownership is suspicious for non-bundled plugins (POSIX owner is neither current uid nor root).
|
||||
- Loaded non-bundled plugins without install/load-path provenance emit a warning so you can pin trust (`plugins.allow`) or install tracking (`plugins.installs`).
|
||||
|
||||
Each plugin must include a `openclaw.plugin.json` file in its root. If a path
|
||||
points at a file, the plugin root is the file's directory and must contain the
|
||||
manifest.
|
||||
Each native OpenClaw plugin must include a `openclaw.plugin.json` file in its
|
||||
root. If a path points at a file, the plugin root is the file's directory and
|
||||
must contain the manifest.
|
||||
|
||||
Compatible bundles may instead provide one of:
|
||||
|
||||
- `.codex-plugin/plugin.json`
|
||||
- `.claude-plugin/plugin.json`
|
||||
|
||||
Bundle directories are discovered from the same roots as native plugins.
|
||||
|
||||
If multiple plugins resolve to the same id, the first match in the order above
|
||||
wins and lower-precedence copies are ignored.
|
||||
@ -394,9 +737,8 @@ Enablement is resolved after discovery:
|
||||
- channel config implicitly enables the bundled channel plugin
|
||||
- exclusive slots can force-enable the selected plugin for that slot
|
||||
|
||||
In current core, bundled default-on ids include local/provider helpers such as
|
||||
`ollama`, `sglang`, `vllm`, plus `device-pair`, `phone-control`, and
|
||||
`talk-voice`.
|
||||
In current core, bundled default-on ids include the local/provider helpers
|
||||
above plus the active memory slot plugin.
|
||||
|
||||
### Package packs
|
||||
|
||||
@ -406,7 +748,8 @@ A plugin directory may include a `package.json` with `openclaw.extensions`:
|
||||
{
|
||||
"name": "my-pack",
|
||||
"openclaw": {
|
||||
"extensions": ["./src/safety.ts", "./src/tools.ts"]
|
||||
"extensions": ["./src/safety.ts", "./src/tools.ts"],
|
||||
"setupEntry": "./src/setup-entry.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -425,6 +768,13 @@ Security note: `openclaw plugins install` installs plugin dependencies with
|
||||
`npm install --ignore-scripts` (no lifecycle scripts). Keep plugin dependency
|
||||
trees "pure JS/TS" and avoid packages that require `postinstall` builds.
|
||||
|
||||
Optional: `openclaw.setupEntry` can point at a lightweight setup-only module.
|
||||
When OpenClaw needs onboarding/setup surfaces for a disabled channel plugin, or
|
||||
when a channel plugin is enabled but still unconfigured, it loads `setupEntry`
|
||||
instead of the full plugin entry. This keeps startup and onboarding lighter
|
||||
when your main plugin entry also wires tools, hooks, or other runtime-only
|
||||
code.
|
||||
|
||||
### Channel catalog metadata
|
||||
|
||||
Channel plugins can advertise onboarding metadata via `openclaw.channel` and
|
||||
@ -537,8 +887,9 @@ Validation rules (strict):
|
||||
- Unknown plugin ids in `entries`, `allow`, `deny`, or `slots` are **errors**.
|
||||
- Unknown `channels.<id>` keys are **errors** unless a plugin manifest declares
|
||||
the channel id.
|
||||
- Plugin config is validated using the JSON Schema embedded in
|
||||
- Native plugin config is validated using the JSON Schema embedded in
|
||||
`openclaw.plugin.json` (`configSchema`).
|
||||
- Compatible bundles currently do not expose native OpenClaw config schemas.
|
||||
- If a plugin is disabled, its config is preserved and a **warning** is emitted.
|
||||
|
||||
### Disabled vs missing vs invalid
|
||||
@ -638,6 +989,10 @@ openclaw plugins disable <id>
|
||||
openclaw plugins doctor
|
||||
```
|
||||
|
||||
`openclaw plugins list` shows the top-level format as `openclaw` or `bundle`.
|
||||
Verbose list/info output also shows bundle subtype (`codex` or `claude`) plus
|
||||
detected bundle capabilities.
|
||||
|
||||
`plugins update` only works for npm installs tracked under `plugins.installs`.
|
||||
If stored integrity metadata changes between updates, OpenClaw warns and asks for confirmation (use global `--yes` to bypass prompts).
|
||||
|
||||
@ -1082,28 +1437,23 @@ Notes:
|
||||
- `meta.preferOver` lists channel ids to skip auto-enable when both are configured.
|
||||
- `meta.detailLabel` and `meta.systemImage` let UIs show richer channel labels/icons.
|
||||
|
||||
### Channel onboarding hooks
|
||||
### Channel setup hooks
|
||||
|
||||
Channel plugins can define optional onboarding hooks on `plugin.onboarding`:
|
||||
Preferred setup split:
|
||||
|
||||
- `configure(ctx)` is the baseline setup flow.
|
||||
- `configureInteractive(ctx)` can fully own interactive setup for both configured and unconfigured states.
|
||||
- `configureWhenConfigured(ctx)` can override behavior only for already configured channels.
|
||||
- `plugin.setup` owns account-id normalization, validation, and config writes.
|
||||
- `plugin.setupWizard` lets the host run the common wizard flow while the channel only supplies status, credential, DM allowlist, and channel-access descriptors.
|
||||
|
||||
Hook precedence in the wizard:
|
||||
`plugin.setupWizard` is best for channels that fit the shared pattern:
|
||||
|
||||
1. `configureInteractive` (if present)
|
||||
2. `configureWhenConfigured` (only when channel status is already configured)
|
||||
3. fallback to `configure`
|
||||
|
||||
Context details:
|
||||
|
||||
- `configureInteractive` and `configureWhenConfigured` receive:
|
||||
- `configured` (`true` or `false`)
|
||||
- `label` (user-facing channel name used by prompts)
|
||||
- plus the shared config/runtime/prompter/options fields
|
||||
- Returning `"skip"` leaves selection and account tracking unchanged.
|
||||
- Returning `{ cfg, accountId? }` applies config updates and records account selection.
|
||||
- one account picker driven by `plugin.config.listAccountIds`
|
||||
- optional preflight/prepare step before prompting (for example installer/bootstrap work)
|
||||
- optional env-shortcut prompt for bundled credential sets (for example paired bot/app tokens)
|
||||
- one or more credential prompts, with each step either writing through `plugin.setup.applyAccountConfig` or a channel-owned partial patch
|
||||
- optional non-secret text prompts (for example CLI paths, base URLs, account ids)
|
||||
- optional channel/group access allowlist prompts resolved by the host
|
||||
- optional DM allowlist resolution (for example `@username` -> numeric id)
|
||||
- optional completion note after setup finishes
|
||||
|
||||
### Write a new messaging channel (step‑by‑step)
|
||||
|
||||
@ -1130,7 +1480,7 @@ Model provider docs live under `/providers/*`.
|
||||
|
||||
4. Add optional adapters as needed
|
||||
|
||||
- `setup` (wizard), `security` (DM policy), `status` (health/diagnostics)
|
||||
- `setup` (validation + config writes), `setupWizard` (host-owned wizard), `security` (DM policy), `status` (health/diagnostics)
|
||||
- `gateway` (start/stop/login), `mentions`, `threading`, `streaming`
|
||||
- `actions` (message actions), `commands` (native command behavior)
|
||||
|
||||
@ -1314,6 +1664,7 @@ Recommended packaging:
|
||||
Publishing contract:
|
||||
|
||||
- Plugin `package.json` must include `openclaw.extensions` with one or more entry files.
|
||||
- Optional: `openclaw.setupEntry` may point at a lightweight setup-only entry for disabled or still-unconfigured channel onboarding/setup.
|
||||
- Entry files can be `.js` or `.ts` (jiti loads TS at runtime).
|
||||
- `openclaw plugins install <npm-spec>` uses `npm pack`, extracts into `~/.openclaw/extensions/<id>/`, and enables it in config.
|
||||
- Config key stability: scoped packages are normalized to the **unscoped** id for `plugins.entries.*`.
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
- 目标文档:`docs/zh-CN/**/*.md`
|
||||
- 术语表:`docs/.i18n/glossary.zh-CN.json`
|
||||
- 翻译记忆库:`docs/.i18n/zh-CN.tm.jsonl`
|
||||
- 提示词规则:`scripts/docs-i18n/translator.go`
|
||||
- 提示词规则:`scripts/docs-i18n/prompt.go`
|
||||
|
||||
常用运行方式:
|
||||
|
||||
@ -31,6 +31,8 @@ go run scripts/docs-i18n/main.go -mode segment docs/channels/matrix.md
|
||||
注意事项:
|
||||
|
||||
- doc 模式用于整页翻译;segment 模式用于小范围修补(依赖 TM)。
|
||||
- 新增技术术语、页面标题或短导航标签时,先更新 `docs/.i18n/glossary.zh-CN.json`,再跑 `doc` 模式;不要指望模型自行保留英文术语或固定译名。
|
||||
- `pnpm docs:check-i18n-glossary` 会检查变更过的英文文档标题和短内部链接标签是否已写入 glossary。
|
||||
- 超大文件若超时,优先做**定点替换**或拆分后再跑。
|
||||
- 翻译后检查中文引号、CJK-Latin 间距和术语一致性。
|
||||
|
||||
|
||||
@ -589,7 +589,7 @@ Gmail Pub/Sub 钩子设置 + 运行器。参见 [/automation/gmail-pubsub](/auto
|
||||
说明:
|
||||
|
||||
- 数据直接来自提供商用量端点(非估算)。
|
||||
- 提供商:Anthropic、GitHub Copilot、OpenAI Codex OAuth,以及启用这些提供商插件时的 Gemini CLI/Antigravity。
|
||||
- 提供商:Anthropic、GitHub Copilot、OpenAI Codex OAuth,以及通过捆绑 `google` 插件提供的 Gemini CLI 和已配置的 Antigravity。
|
||||
- 如果没有匹配的凭证,用量会被隐藏。
|
||||
- 详情:参见[用量跟踪](/concepts/usage-tracking)。
|
||||
|
||||
|
||||
@ -1,153 +1,265 @@
|
||||
---
|
||||
read_when:
|
||||
- 你需要按提供商分类的模型设置参考
|
||||
- 你需要一份逐提供商的模型设置参考
|
||||
- 你需要模型提供商的示例配置或 CLI 新手引导命令
|
||||
summary: 模型提供商概述,包含示例配置和 CLI 流程
|
||||
summary: 模型提供商概览,包含示例配置和 CLI 流程
|
||||
title: 模型提供商
|
||||
x-i18n:
|
||||
generated_at: "2026-02-03T07:46:28Z"
|
||||
model: claude-opus-4-5
|
||||
generated_at: "2026-03-16T02:12:40Z"
|
||||
model: claude-opus-4-6
|
||||
provider: pi
|
||||
source_hash: 14f73e5a9f9b7c6f017d59a54633942dba95a3eb50f8848b836cfe0b9f6d7719
|
||||
source_hash: 978798c80c5809c162f9807072ab48fdf99bfe0db39b2b3c245ce8b4e5451603
|
||||
source_path: concepts/model-providers.md
|
||||
workflow: 15
|
||||
---
|
||||
|
||||
# 模型提供商
|
||||
|
||||
本页介绍 **LLM/模型提供商**(不是 WhatsApp/Telegram 等聊天渠道)。
|
||||
关于模型选择规则,请参阅 [/concepts/models](/concepts/models)。
|
||||
本页涵盖 **LLM/模型提供商** (不是 WhatsApp/Telegram 等聊天渠道)。
|
||||
有关模型选择规则,请参阅 [/concepts/models](/concepts/models)。
|
||||
|
||||
## 快速规则
|
||||
|
||||
- 模型引用使用 `provider/model` 格式(例如:`opencode/claude-opus-4-5`)。
|
||||
- 如果设置了 `agents.defaults.models`,它将成为允许列表。
|
||||
- CLI 辅助工具:`openclaw onboard`、`openclaw models list`、`openclaw models set <provider/model>`。
|
||||
- 模型引用使用 `provider/model` (例如: `opencode/claude-opus-4-6`)。
|
||||
- 如果你设置了 `agents.defaults.models`,它将成为允许列表。
|
||||
- CLI 辅助命令: `openclaw onboard`, `openclaw models list`, `openclaw models set <provider/model>`。
|
||||
- 提供商插件可以通过以下方式注入模型目录 `registerProvider({ catalog })`;
|
||||
OpenClaw 将该输出合并到 `models.providers` 之后再写入
|
||||
`models.json`。
|
||||
- 提供商插件还可以通过以下方式控制提供商的运行时行为
|
||||
`resolveDynamicModel`, `prepareDynamicModel`, `normalizeResolvedModel`,
|
||||
`capabilities`, `prepareExtraParams`, `wrapStreamFn`,
|
||||
`isCacheTtlEligible`, `prepareRuntimeAuth`, `resolveUsageAuth`,以及
|
||||
`fetchUsageSnapshot`。
|
||||
|
||||
## 插件管理的提供商行为
|
||||
|
||||
提供商插件现在可以管理大部分提供商特定逻辑,而 OpenClaw 负责维护通用推理循环。
|
||||
|
||||
典型分工:
|
||||
|
||||
- `catalog`:提供商出现在 `models.providers`
|
||||
- `resolveDynamicModel`:提供商接受尚未出现在本地静态目录中的模型 ID
|
||||
- `prepareDynamicModel`:提供商在重试动态解析之前需要刷新元数据
|
||||
- `normalizeResolvedModel`:提供商需要传输层或基础 URL 重写
|
||||
- `capabilities`:提供商发布会话记录/工具/提供商系列的特殊行为
|
||||
- `prepareExtraParams`:提供商默认或规范化每个模型的请求参数
|
||||
- `wrapStreamFn`:提供商应用请求头/请求体/模型兼容性封装
|
||||
- `isCacheTtlEligible`:提供商决定哪些上游模型 ID 支持 prompt-cache TTL
|
||||
- `prepareRuntimeAuth`:提供商将配置的凭证转换为短期运行时令牌
|
||||
- `resolveUsageAuth`:提供商为以下用途解析使用量/配额凭证 `/usage`
|
||||
以及相关的状态/报告界面
|
||||
- `fetchUsageSnapshot`:提供商负责使用量端点的获取/解析,而核心仍负责摘要外壳和格式化
|
||||
|
||||
当前内置示例:
|
||||
|
||||
- `anthropic`:Claude 4.6 向前兼容回退、使用量端点获取,以及 cache-TTL/提供商系列元数据
|
||||
- `openrouter`:直通模型 ID、请求封装、提供商能力提示,以及 cache-TTL 策略
|
||||
- `github-copilot`:向前兼容模型回退、Claude-thinking 会话记录提示、运行时令牌交换,以及使用量端点获取
|
||||
- `openai`:GPT-5.4 向前兼容回退、直接 OpenAI 传输规范化,以及提供商系列元数据
|
||||
- `openai-codex`:向前兼容模型回退、传输规范化,以及默认传输参数和使用量端点获取
|
||||
- `google-gemini-cli`:Gemini 3.1 向前兼容回退,以及使用量界面的 usage-token 解析和配额端点获取
|
||||
- `moonshot`:共享传输、插件管理的 thinking 负载规范化
|
||||
- `kilocode`:共享传输、插件管理的请求头、推理负载规范化、Gemini 会话记录提示,以及 cache-TTL 策略
|
||||
- `zai`:GLM-5 向前兼容回退, `tool_stream` 默认值、cache-TTL 策略,以及使用量认证和配额获取
|
||||
- `mistral`, `opencode`,以及`opencode-go`:插件管理的能力元数据
|
||||
- `byteplus`, `cloudflare-ai-gateway`, `huggingface`, `kimi-coding`,
|
||||
`minimax-portal`, `modelstudio`, `nvidia`, `qianfan`, `qwen-portal`,
|
||||
`synthetic`, `together`, `venice`, `vercel-ai-gateway`,以及`volcengine`:仅限插件管理的目录
|
||||
- `minimax` 和 `xiaomi`:插件管理的目录以及使用量认证/快照逻辑
|
||||
|
||||
以上涵盖了仍然适用于 OpenClaw 常规传输层的提供商。如果某个提供商需要完全自定义的请求执行器,则属于一个独立的、更深层的扩展层面。
|
||||
|
||||
## API 密钥轮换
|
||||
|
||||
- 支持对选定提供商的通用提供商轮换。
|
||||
- 通过以下方式配置多个密钥:
|
||||
- `OPENCLAW_LIVE_<PROVIDER>_KEY` (单个实时覆盖,最高优先级)
|
||||
- `<PROVIDER>_API_KEYS` (逗号或分号分隔的列表)
|
||||
- `<PROVIDER>_API_KEY` (主密钥)
|
||||
- `<PROVIDER>_API_KEY_*` (编号列表,例如 `<PROVIDER>_API_KEY_1`)
|
||||
- 对于 Google 提供商, `GOOGLE_API_KEY` 也作为备选项包含在内。
|
||||
- 密钥选择顺序按优先级排列并去除重复值。
|
||||
- 仅在速率限制响应时使用下一个密钥重试请求(例如 `429`, `rate_limit`, `quota`, `resource exhausted`)。
|
||||
- 非速率限制的失败会立即报错;不会尝试密钥轮换。
|
||||
- 当所有候选密钥均失败时,返回最后一次尝试的错误。
|
||||
|
||||
## 内置提供商(pi-ai 目录)
|
||||
|
||||
OpenClaw 附带 pi-ai 目录。这些提供商**不需要** `models.providers` 配置;只需设置认证 + 选择模型。
|
||||
OpenClaw 附带 pi-ai 目录。这些提供商需要 **无需**
|
||||
`models.providers` 配置;只需设置认证并选择一个模型。
|
||||
|
||||
### OpenAI
|
||||
|
||||
- 提供商:`openai`
|
||||
- 认证:`OPENAI_API_KEY`
|
||||
- 示例模型:`openai/gpt-5.2`
|
||||
- CLI:`openclaw onboard --auth-choice openai-api-key`
|
||||
- 提供商: `openai`
|
||||
- 认证: `OPENAI_API_KEY`
|
||||
- 可选轮换: `OPENAI_API_KEYS`, `OPENAI_API_KEY_1`, `OPENAI_API_KEY_2`,加上 `OPENCLAW_LIVE_OPENAI_KEY` (单个覆盖)
|
||||
- 示例模型: `openai/gpt-5.4`, `openai/gpt-5.4-pro`
|
||||
- CLI: `openclaw onboard --auth-choice openai-api-key`
|
||||
- 默认传输为 `auto` (WebSocket 优先,SSE 备选)
|
||||
- 通过以下方式覆盖每个模型 `agents.defaults.models["openai/<model>"].params.transport` (`"sse"`, `"websocket"`,或 `"auto"`)
|
||||
- OpenAI Responses WebSocket 预热默认通过以下方式启用 `params.openaiWsWarmup` (`true`/`false`)
|
||||
- OpenAI 优先处理可以通过以下方式启用 `agents.defaults.models["openai/<model>"].params.serviceTier`
|
||||
- OpenAI 快速模式可以通过以下方式为每个模型启用 `agents.defaults.models["<provider>/<model>"].params.fastMode`
|
||||
- `openai/gpt-5.3-codex-spark` 在 OpenClaw 中被有意屏蔽,因为 OpenAI 实时 API 会拒绝它;Spark 被视为仅限 Codex 使用
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: { defaults: { model: { primary: "openai/gpt-5.2" } } },
|
||||
agents: { defaults: { model: { primary: "openai/gpt-5.4" } } },
|
||||
}
|
||||
```
|
||||
|
||||
### Anthropic
|
||||
|
||||
- 提供商:`anthropic`
|
||||
- 认证:`ANTHROPIC_API_KEY` 或 `claude setup-token`
|
||||
- 示例模型:`anthropic/claude-opus-4-5`
|
||||
- CLI:`openclaw onboard --auth-choice token`(粘贴 setup-token)或 `openclaw models auth paste-token --provider anthropic`
|
||||
- 提供商: `anthropic`
|
||||
- 认证: `ANTHROPIC_API_KEY` 或 `claude setup-token`
|
||||
- 可选轮换: `ANTHROPIC_API_KEYS`, `ANTHROPIC_API_KEY_1`, `ANTHROPIC_API_KEY_2`,加上 `OPENCLAW_LIVE_ANTHROPIC_KEY` (单个覆盖)
|
||||
- 示例模型: `anthropic/claude-opus-4-6`
|
||||
- CLI: `openclaw onboard --auth-choice token` (粘贴 setup-token)或 `openclaw models auth paste-token --provider anthropic`
|
||||
- 直接 API 密钥模型支持共享的 `/fast` 切换和 `params.fastMode`;OpenClaw 将其映射到 Anthropic 的 `service_tier` (`auto` 与 `standard_only`)
|
||||
- 策略说明:setup-token 支持属于技术兼容性;Anthropic 过去曾阻止部分订阅在 Claude Code 之外的使用。请核实当前 Anthropic 条款,并根据你的风险承受能力做出决定。
|
||||
- 建议:Anthropic API 密钥认证是比订阅 setup-token 认证更安全的推荐方式。
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
|
||||
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-6" } } },
|
||||
}
|
||||
```
|
||||
|
||||
### OpenAI Code (Codex)
|
||||
|
||||
- 提供商:`openai-codex`
|
||||
- 提供商: `openai-codex`
|
||||
- 认证:OAuth (ChatGPT)
|
||||
- 示例模型:`openai-codex/gpt-5.2`
|
||||
- CLI:`openclaw onboard --auth-choice openai-codex` 或 `openclaw models auth login --provider openai-codex`
|
||||
- 示例模型: `openai-codex/gpt-5.4`
|
||||
- CLI: `openclaw onboard --auth-choice openai-codex` 或 `openclaw models auth login --provider openai-codex`
|
||||
- 默认传输为 `auto` (WebSocket 优先,SSE 备选)
|
||||
- 通过以下方式覆盖每个模型 `agents.defaults.models["openai-codex/<model>"].params.transport` (`"sse"`, `"websocket"`,或 `"auto"`)
|
||||
- 与相同的 `/fast` 切换和 `params.fastMode` 配置共享,如同直接的 `openai/*`
|
||||
- `openai-codex/gpt-5.3-codex-spark` 当 Codex OAuth 目录公开时仍然可用;取决于授权资格
|
||||
- 策略说明:OpenAI Codex OAuth 明确支持 OpenClaw 等外部工具/工作流。
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: { defaults: { model: { primary: "openai-codex/gpt-5.2" } } },
|
||||
agents: { defaults: { model: { primary: "openai-codex/gpt-5.4" } } },
|
||||
}
|
||||
```
|
||||
|
||||
### OpenCode Zen
|
||||
### OpenCode
|
||||
|
||||
- 提供商:`opencode`
|
||||
- 认证:`OPENCODE_API_KEY`(或 `OPENCODE_ZEN_API_KEY`)
|
||||
- 示例模型:`opencode/claude-opus-4-5`
|
||||
- CLI:`openclaw onboard --auth-choice opencode-zen`
|
||||
- 认证: `OPENCODE_API_KEY` (或 `OPENCODE_ZEN_API_KEY`)
|
||||
- Zen 运行时提供商: `opencode`
|
||||
- Go 运行时提供商: `opencode-go`
|
||||
- 示例模型: `opencode/claude-opus-4-6`, `opencode-go/kimi-k2.5`
|
||||
- CLI: `openclaw onboard --auth-choice opencode-zen` 或 `openclaw onboard --auth-choice opencode-go`
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: { defaults: { model: { primary: "opencode/claude-opus-4-5" } } },
|
||||
agents: { defaults: { model: { primary: "opencode/claude-opus-4-6" } } },
|
||||
}
|
||||
```
|
||||
|
||||
### Google Gemini(API 密钥)
|
||||
|
||||
- 提供商:`google`
|
||||
- 认证:`GEMINI_API_KEY`
|
||||
- 示例模型:`google/gemini-3-pro-preview`
|
||||
- CLI:`openclaw onboard --auth-choice gemini-api-key`
|
||||
- 提供商: `google`
|
||||
- 认证: `GEMINI_API_KEY`
|
||||
- 可选轮换: `GEMINI_API_KEYS`, `GEMINI_API_KEY_1`, `GEMINI_API_KEY_2`, `GOOGLE_API_KEY` 备选,以及 `OPENCLAW_LIVE_GEMINI_KEY` (单个覆盖)
|
||||
- 示例模型: `google/gemini-3.1-pro-preview`, `google/gemini-3-flash-preview`
|
||||
- 兼容性:使用旧版 OpenClaw 配置的 `google/gemini-3.1-flash-preview` 会被规范化为 `google/gemini-3-flash-preview`
|
||||
- CLI: `openclaw onboard --auth-choice gemini-api-key`
|
||||
|
||||
### Google Vertex、Antigravity 和 Gemini CLI
|
||||
### Google Vertex 和 Gemini CLI
|
||||
|
||||
- 提供商:`google-vertex`、`google-antigravity`、`google-gemini-cli`
|
||||
- 认证:Vertex 使用 gcloud ADC;Antigravity/Gemini CLI 使用各自的认证流程
|
||||
- Antigravity OAuth 作为捆绑插件提供(`google-antigravity-auth`,默认禁用)。
|
||||
- 启用:`openclaw plugins enable google-antigravity-auth`
|
||||
- 登录:`openclaw models auth login --provider google-antigravity --set-default`
|
||||
- Gemini CLI OAuth 作为捆绑插件提供(`google-gemini-cli-auth`,默认禁用)。
|
||||
- 启用:`openclaw plugins enable google-gemini-cli-auth`
|
||||
- 登录:`openclaw models auth login --provider google-gemini-cli --set-default`
|
||||
- 注意:你**不需要**将客户端 ID 或密钥粘贴到 `openclaw.json` 中。CLI 登录流程将令牌存储在 Gateway 网关主机的认证配置文件中。
|
||||
- 提供商: `google-vertex`, `google-gemini-cli`
|
||||
- 认证:Vertex 使用 gcloud ADC;Gemini CLI 使用其 OAuth 流程
|
||||
- 注意:OpenClaw 中的 Gemini CLI OAuth 是非官方集成。部分用户报告称在使用第三方客户端后 Google 账户受到限制。请查阅 Google 条款,如果你选择继续,建议使用非关键账户。
|
||||
- Gemini CLI OAuth 作为内置的 `google` 插件的一部分提供。
|
||||
- 启用: `openclaw plugins enable google`
|
||||
- 登录: `openclaw models auth login --provider google-gemini-cli --set-default`
|
||||
- 注意:你确实 **不** 需要将 client ID 或 secret 粘贴到 `openclaw.json`中。CLI 登录流程将令牌存储在 Gateway 网关主机的认证配置文件中。
|
||||
|
||||
### Z.AI (GLM)
|
||||
|
||||
- 提供商:`zai`
|
||||
- 认证:`ZAI_API_KEY`
|
||||
- 示例模型:`zai/glm-4.7`
|
||||
- CLI:`openclaw onboard --auth-choice zai-api-key`
|
||||
- 别名:`z.ai/*` 和 `z-ai/*` 规范化为 `zai/*`
|
||||
- 提供商: `zai`
|
||||
- 认证: `ZAI_API_KEY`
|
||||
- 示例模型: `zai/glm-5`
|
||||
- CLI: `openclaw onboard --auth-choice zai-api-key`
|
||||
- 别名: `z.ai/*` 和 `z-ai/*` 规范化为 `zai/*`
|
||||
|
||||
### Vercel AI Gateway
|
||||
|
||||
- 提供商:`vercel-ai-gateway`
|
||||
- 认证:`AI_GATEWAY_API_KEY`
|
||||
- 示例模型:`vercel-ai-gateway/anthropic/claude-opus-4.5`
|
||||
- CLI:`openclaw onboard --auth-choice ai-gateway-api-key`
|
||||
- 提供商: `vercel-ai-gateway`
|
||||
- 认证: `AI_GATEWAY_API_KEY`
|
||||
- 示例模型: `vercel-ai-gateway/anthropic/claude-opus-4.6`
|
||||
- CLI: `openclaw onboard --auth-choice ai-gateway-api-key`
|
||||
|
||||
### 其他内置提供商
|
||||
### Kilo Gateway
|
||||
|
||||
- OpenRouter:`openrouter`(`OPENROUTER_API_KEY`)
|
||||
- 示例模型:`openrouter/anthropic/claude-sonnet-4-5`
|
||||
- xAI:`xai`(`XAI_API_KEY`)
|
||||
- Groq:`groq`(`GROQ_API_KEY`)
|
||||
- Cerebras:`cerebras`(`CEREBRAS_API_KEY`)
|
||||
- 提供商: `kilocode`
|
||||
- 认证: `KILOCODE_API_KEY`
|
||||
- 示例模型: `kilocode/anthropic/claude-opus-4.6`
|
||||
- CLI: `openclaw onboard --kilocode-api-key <key>`
|
||||
- 基础 URL: `https://api.kilo.ai/api/gateway/`
|
||||
- 扩展的内置目录包括 GLM-5 Free、MiniMax M2.5 Free、GPT-5.2、Gemini 3 Pro Preview、Gemini 3 Flash Preview、Grok Code Fast 1 和 Kimi K2.5。
|
||||
|
||||
参阅 [/providers/kilocode](/providers/kilocode) 了解详情。
|
||||
|
||||
### 其他内置提供商插件
|
||||
|
||||
- OpenRouter: `openrouter` (`OPENROUTER_API_KEY`)
|
||||
- 示例模型: `openrouter/anthropic/claude-sonnet-4-5`
|
||||
- Kilo Gateway: `kilocode` (`KILOCODE_API_KEY`)
|
||||
- 示例模型: `kilocode/anthropic/claude-opus-4.6`
|
||||
- MiniMax: `minimax` (`MINIMAX_API_KEY`)
|
||||
- Moonshot: `moonshot` (`MOONSHOT_API_KEY`)
|
||||
- Kimi Coding: `kimi-coding` (`KIMI_API_KEY` 或 `KIMICODE_API_KEY`)
|
||||
- Qianfan: `qianfan` (`QIANFAN_API_KEY`)
|
||||
- Model Studio: `modelstudio` (`MODELSTUDIO_API_KEY`)
|
||||
- NVIDIA: `nvidia` (`NVIDIA_API_KEY`)
|
||||
- Together: `together` (`TOGETHER_API_KEY`)
|
||||
- Venice: `venice` (`VENICE_API_KEY`)
|
||||
- Xiaomi: `xiaomi` (`XIAOMI_API_KEY`)
|
||||
- Vercel AI Gateway: `vercel-ai-gateway` (`AI_GATEWAY_API_KEY`)
|
||||
- Hugging Face Inference: `huggingface` (`HUGGINGFACE_HUB_TOKEN` 或 `HF_TOKEN`)
|
||||
- Cloudflare AI Gateway: `cloudflare-ai-gateway` (`CLOUDFLARE_AI_GATEWAY_API_KEY`)
|
||||
- Volcengine: `volcengine` (`VOLCANO_ENGINE_API_KEY`)
|
||||
- BytePlus: `byteplus` (`BYTEPLUS_API_KEY`)
|
||||
- xAI: `xai` (`XAI_API_KEY`)
|
||||
- Mistral: `mistral` (`MISTRAL_API_KEY`)
|
||||
- 示例模型: `mistral/mistral-large-latest`
|
||||
- CLI: `openclaw onboard --auth-choice mistral-api-key`
|
||||
- Groq: `groq` (`GROQ_API_KEY`)
|
||||
- Cerebras: `cerebras` (`CEREBRAS_API_KEY`)
|
||||
- Cerebras 上的 GLM 模型使用 ID `zai-glm-4.7` 和 `zai-glm-4.6`。
|
||||
- OpenAI 兼容的基础 URL:`https://api.cerebras.ai/v1`。
|
||||
- Mistral:`mistral`(`MISTRAL_API_KEY`)
|
||||
- GitHub Copilot:`github-copilot`(`COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN`)
|
||||
- 兼容 OpenAI 的基础 URL: `https://api.cerebras.ai/v1`。
|
||||
- GitHub Copilot: `github-copilot` (`COPILOT_GITHUB_TOKEN`/`GH_TOKEN`/`GITHUB_TOKEN`)
|
||||
- Hugging Face Inference 示例模型: `huggingface/deepseek-ai/DeepSeek-R1`;CLI: `openclaw onboard --auth-choice huggingface-api-key`。参阅 [Hugging Face (Inference)](/providers/huggingface)。
|
||||
|
||||
## 通过 `models.providers` 配置的提供商(自定义/基础 URL)
|
||||
## 通过以下方式提供的提供商 `models.providers` (自定义/基础 URL)
|
||||
|
||||
使用 `models.providers`(或 `models.json`)添加**自定义**提供商或 OpenAI/Anthropic 兼容的代理。
|
||||
使用 `models.providers` (或 `models.json`)来添加 **自定义** 提供商或 OpenAI/Anthropic 兼容代理。
|
||||
|
||||
下方许多内置提供商插件已经发布了默认目录。
|
||||
使用显式的 `models.providers.<id>` 条目仅在你需要覆盖默认基础 URL、请求头或模型列表时使用。
|
||||
|
||||
### Moonshot AI (Kimi)
|
||||
|
||||
Moonshot 使用 OpenAI 兼容端点,因此将其配置为自定义提供商:
|
||||
Moonshot 使用兼容 OpenAI 的端点,因此将其配置为自定义提供商:
|
||||
|
||||
- 提供商:`moonshot`
|
||||
- 认证:`MOONSHOT_API_KEY`
|
||||
- 示例模型:`moonshot/kimi-k2.5`
|
||||
- 提供商: `moonshot`
|
||||
- 认证: `MOONSHOT_API_KEY`
|
||||
- 示例模型: `moonshot/kimi-k2.5`
|
||||
|
||||
Kimi K2 模型 ID:
|
||||
|
||||
{/_ moonshot-kimi-k2-model-refs:start _/ && null}
|
||||
[//]: # "moonshot-kimi-k2-model-refs:start"
|
||||
|
||||
- `moonshot/kimi-k2.5`
|
||||
- `moonshot/kimi-k2-0905-preview`
|
||||
- `moonshot/kimi-k2-turbo-preview`
|
||||
- `moonshot/kimi-k2-thinking`
|
||||
- `moonshot/kimi-k2-thinking-turbo`
|
||||
{/_ moonshot-kimi-k2-model-refs:end _/ && null}
|
||||
|
||||
[//]: # "moonshot-kimi-k2-model-refs:end"
|
||||
|
||||
```json5
|
||||
{
|
||||
@ -172,9 +284,9 @@ Kimi K2 模型 ID:
|
||||
|
||||
Kimi Coding 使用 Moonshot AI 的 Anthropic 兼容端点:
|
||||
|
||||
- 提供商:`kimi-coding`
|
||||
- 认证:`KIMI_API_KEY`
|
||||
- 示例模型:`kimi-coding/k2p5`
|
||||
- 提供商: `kimi-coding`
|
||||
- 认证: `KIMI_API_KEY`
|
||||
- 示例模型: `kimi-coding/k2p5`
|
||||
|
||||
```json5
|
||||
{
|
||||
@ -185,13 +297,12 @@ Kimi Coding 使用 Moonshot AI 的 Anthropic 兼容端点:
|
||||
}
|
||||
```
|
||||
|
||||
### Qwen OAuth(免费层级)
|
||||
### Qwen OAuth(免费套餐)
|
||||
|
||||
Qwen 通过设备码流程提供对 Qwen Coder + Vision 的 OAuth 访问。
|
||||
启用捆绑插件,然后登录:
|
||||
内置提供商插件默认启用,只需登录:
|
||||
|
||||
```bash
|
||||
openclaw plugins enable qwen-portal-auth
|
||||
openclaw models auth login --provider qwen-portal --set-default
|
||||
```
|
||||
|
||||
@ -200,21 +311,85 @@ openclaw models auth login --provider qwen-portal --set-default
|
||||
- `qwen-portal/coder-model`
|
||||
- `qwen-portal/vision-model`
|
||||
|
||||
参见 [/providers/qwen](/providers/qwen) 了解设置详情和注意事项。
|
||||
参阅 [/providers/qwen](/providers/qwen) 了解详情和注意事项。
|
||||
|
||||
### Synthetic
|
||||
### 火山引擎(豆包)
|
||||
|
||||
Synthetic 通过 `synthetic` 提供商提供 Anthropic 兼容模型:
|
||||
火山引擎提供对豆包及中国其他模型的访问。
|
||||
|
||||
- 提供商:`synthetic`
|
||||
- 认证:`SYNTHETIC_API_KEY`
|
||||
- 示例模型:`synthetic/hf:MiniMaxAI/MiniMax-M2.1`
|
||||
- CLI:`openclaw onboard --auth-choice synthetic-api-key`
|
||||
- 提供商: `volcengine` (编码: `volcengine-plan`)
|
||||
- 认证: `VOLCANO_ENGINE_API_KEY`
|
||||
- 示例模型: `volcengine/doubao-seed-1-8-251228`
|
||||
- CLI: `openclaw onboard --auth-choice volcengine-api-key`
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: { model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.1" } },
|
||||
defaults: { model: { primary: "volcengine/doubao-seed-1-8-251228" } },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
可用模型:
|
||||
|
||||
- `volcengine/doubao-seed-1-8-251228` (豆包 Seed 1.8)
|
||||
- `volcengine/doubao-seed-code-preview-251028`
|
||||
- `volcengine/kimi-k2-5-260127` (Kimi K2.5)
|
||||
- `volcengine/glm-4-7-251222` (GLM 4.7)
|
||||
- `volcengine/deepseek-v3-2-251201` (DeepSeek V3.2 128K)
|
||||
|
||||
编码模型(`volcengine-plan`):
|
||||
|
||||
- `volcengine-plan/ark-code-latest`
|
||||
- `volcengine-plan/doubao-seed-code`
|
||||
- `volcengine-plan/kimi-k2.5`
|
||||
- `volcengine-plan/kimi-k2-thinking`
|
||||
- `volcengine-plan/glm-4.7`
|
||||
|
||||
### BytePlus(国际版)
|
||||
|
||||
BytePlus ARK 为国际用户提供与火山引擎相同的模型访问。
|
||||
|
||||
- 提供商: `byteplus` (编码: `byteplus-plan`)
|
||||
- 认证: `BYTEPLUS_API_KEY`
|
||||
- 示例模型: `byteplus/seed-1-8-251228`
|
||||
- CLI: `openclaw onboard --auth-choice byteplus-api-key`
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: { model: { primary: "byteplus/seed-1-8-251228" } },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
可用模型:
|
||||
|
||||
- `byteplus/seed-1-8-251228` (Seed 1.8)
|
||||
- `byteplus/kimi-k2-5-260127` (Kimi K2.5)
|
||||
- `byteplus/glm-4-7-251222` (GLM 4.7)
|
||||
|
||||
编码模型(`byteplus-plan`):
|
||||
|
||||
- `byteplus-plan/ark-code-latest`
|
||||
- `byteplus-plan/doubao-seed-code`
|
||||
- `byteplus-plan/kimi-k2.5`
|
||||
- `byteplus-plan/kimi-k2-thinking`
|
||||
- `byteplus-plan/glm-4.7`
|
||||
|
||||
### Synthetic
|
||||
|
||||
Synthetic 提供 Anthropic 兼容模型,位于 `synthetic` 提供商背后:
|
||||
|
||||
- 提供商: `synthetic`
|
||||
- 认证: `SYNTHETIC_API_KEY`
|
||||
- 示例模型: `synthetic/hf:MiniMaxAI/MiniMax-M2.5`
|
||||
- CLI: `openclaw onboard --auth-choice synthetic-api-key`
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: { model: { primary: "synthetic/hf:MiniMaxAI/MiniMax-M2.5" } },
|
||||
},
|
||||
models: {
|
||||
mode: "merge",
|
||||
@ -223,7 +398,7 @@ Synthetic 通过 `synthetic` 提供商提供 Anthropic 兼容模型:
|
||||
baseUrl: "https://api.synthetic.new/anthropic",
|
||||
apiKey: "${SYNTHETIC_API_KEY}",
|
||||
api: "anthropic-messages",
|
||||
models: [{ id: "hf:MiniMaxAI/MiniMax-M2.1", name: "MiniMax M2.1" }],
|
||||
models: [{ id: "hf:MiniMaxAI/MiniMax-M2.5", name: "MiniMax M2.5" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -232,21 +407,21 @@ Synthetic 通过 `synthetic` 提供商提供 Anthropic 兼容模型:
|
||||
|
||||
### MiniMax
|
||||
|
||||
MiniMax 通过 `models.providers` 配置,因为它使用自定义端点:
|
||||
MiniMax 通过以下方式配置 `models.providers` ,因为它使用自定义端点:
|
||||
|
||||
- MiniMax(Anthropic 兼容):`--auth-choice minimax-api`
|
||||
- 认证:`MINIMAX_API_KEY`
|
||||
- MiniMax(Anthropic 兼容): `--auth-choice minimax-api`
|
||||
- 认证: `MINIMAX_API_KEY`
|
||||
|
||||
参见 [/providers/minimax](/providers/minimax) 了解设置详情、模型选项和配置片段。
|
||||
参阅 [/providers/minimax](/providers/minimax) 了解详情、模型选项和配置代码片段。
|
||||
|
||||
### Ollama
|
||||
|
||||
Ollama 是提供 OpenAI 兼容 API 的本地 LLM 运行时:
|
||||
Ollama 作为内置提供商插件提供,并使用 Ollama 的原生 API:
|
||||
|
||||
- 提供商:`ollama`
|
||||
- 提供商: `ollama`
|
||||
- 认证:无需(本地服务器)
|
||||
- 示例模型:`ollama/llama3.3`
|
||||
- 安装:https://ollama.ai
|
||||
- 示例模型: `ollama/llama3.3`
|
||||
- 安装: [https://ollama.com/download](https://ollama.com/download)
|
||||
|
||||
```bash
|
||||
# Install Ollama, then pull a model:
|
||||
@ -261,18 +436,73 @@ ollama pull llama3.3
|
||||
}
|
||||
```
|
||||
|
||||
当 Ollama 在本地 `http://127.0.0.1:11434/v1` 运行时会自动检测。参见 [/providers/ollama](/providers/ollama) 了解模型推荐和自定义配置。
|
||||
Ollama 在本地通过以下地址检测 `http://127.0.0.1:11434` 当你通过以下方式选择启用时
|
||||
`OLLAMA_API_KEY`,内置提供商插件会将 Ollama 直接添加到
|
||||
`openclaw onboard` 和模型选择器中。参阅 [/providers/ollama](/providers/ollama)
|
||||
了解新手引导、云端/本地模式和自定义配置。
|
||||
|
||||
### vLLM
|
||||
|
||||
vLLM 作为内置提供商插件提供,用于本地/自托管的兼容 OpenAI 服务器:
|
||||
|
||||
- 提供商: `vllm`
|
||||
- 认证:可选(取决于你的服务器)
|
||||
- 默认基础 URL: `http://127.0.0.1:8000/v1`
|
||||
|
||||
要在本地选择启用自动发现(如果你的服务器不强制认证,任何值均可):
|
||||
|
||||
```bash
|
||||
export VLLM_API_KEY="vllm-local"
|
||||
```
|
||||
|
||||
然后设置一个模型(替换为由 `/v1/models`):
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: { model: { primary: "vllm/your-model-id" } },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
参阅 [/providers/vllm](/providers/vllm) 了解详情。
|
||||
|
||||
### SGLang
|
||||
|
||||
SGLang 作为内置提供商插件提供,用于快速自托管的兼容 OpenAI 服务器:
|
||||
|
||||
- 提供商: `sglang`
|
||||
- 认证:可选(取决于你的服务器)
|
||||
- 默认基础 URL: `http://127.0.0.1:30000/v1`
|
||||
|
||||
要在本地选择启用自动发现(如果你的服务器不强制认证,任何值均可):
|
||||
|
||||
```bash
|
||||
export SGLANG_API_KEY="sglang-local"
|
||||
```
|
||||
|
||||
然后设置一个模型(替换为由 `/v1/models`):
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: { model: { primary: "sglang/your-model-id" } },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
参阅 [/providers/sglang](/providers/sglang) 了解详情。
|
||||
|
||||
### 本地代理(LM Studio、vLLM、LiteLLM 等)
|
||||
|
||||
示例(OpenAI 兼容):
|
||||
示例(兼容 OpenAI):
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "lmstudio/minimax-m2.1-gs32" },
|
||||
models: { "lmstudio/minimax-m2.1-gs32": { alias: "Minimax" } },
|
||||
model: { primary: "lmstudio/minimax-m2.5-gs32" },
|
||||
models: { "lmstudio/minimax-m2.5-gs32": { alias: "Minimax" } },
|
||||
},
|
||||
},
|
||||
models: {
|
||||
@ -283,8 +513,8 @@ ollama pull llama3.3
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "minimax-m2.1-gs32",
|
||||
name: "MiniMax M2.1",
|
||||
id: "minimax-m2.5-gs32",
|
||||
name: "MiniMax M2.5",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
@ -300,21 +530,24 @@ ollama pull llama3.3
|
||||
|
||||
注意事项:
|
||||
|
||||
- 对于自定义提供商,`reasoning`、`input`、`cost`、`contextWindow` 和 `maxTokens` 是可选的。
|
||||
- 对于自定义提供商, `reasoning`, `input`, `cost`, `contextWindow`,以及`maxTokens` 是可选的。
|
||||
省略时,OpenClaw 默认为:
|
||||
- `reasoning: false`
|
||||
- `input: ["text"]`
|
||||
- `cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }`
|
||||
- `contextWindow: 200000`
|
||||
- `maxTokens: 8192`
|
||||
- 建议:设置与你的代理/模型限制匹配的显式值。
|
||||
- 建议:设置与你的代理/模型限制相匹配的显式值。
|
||||
- 对于 `api: "openai-completions"` 在非原生端点上(任何非空的 `baseUrl` 且主机不是 `api.openai.com`),OpenClaw 强制使用 `compat.supportsDeveloperRole: false` 以避免提供商对不支持的 `developer` 角色返回 400 错误。
|
||||
- 如果 `baseUrl` 为空/省略,OpenClaw 保持默认的 OpenAI 行为(解析为 `api.openai.com`)。
|
||||
- 为安全起见,显式的 `compat.supportsDeveloperRole: true` 在非原生 `openai-completions` 端点上仍会被覆盖。
|
||||
|
||||
## CLI 示例
|
||||
|
||||
```bash
|
||||
openclaw onboard --auth-choice opencode-zen
|
||||
openclaw models set opencode/claude-opus-4-5
|
||||
openclaw models set opencode/claude-opus-4-6
|
||||
openclaw models list
|
||||
```
|
||||
|
||||
另请参阅:[/gateway/configuration](/gateway/configuration) 了解完整配置示例。
|
||||
另请参阅: [/gateway/configuration](/gateway/configuration) 查看完整配置示例。
|
||||
|
||||
@ -2,10 +2,10 @@
|
||||
summary: 关于 OpenClaw 安装、配置和使用的常见问题
|
||||
title: 常见问题
|
||||
x-i18n:
|
||||
generated_at: "2026-02-01T21:32:04Z"
|
||||
generated_at: "2026-03-16T01:39:16Z"
|
||||
model: claude-opus-4-5
|
||||
provider: pi
|
||||
source_hash: 5a611f2fda3325b1c7a9ec518616d87c78be41e2bfbe86244ae4f48af3815a26
|
||||
source_hash: 6e6a4a63fb73dca24dbe77928b51c6b2e5d51ec883fb36c64e2e40ef027050e9
|
||||
source_path: help/faq.md
|
||||
workflow: 15
|
||||
---
|
||||
@ -687,7 +687,7 @@ Gemini CLI 使用**插件认证流程**,而不是 `openclaw.json` 中的 clien
|
||||
|
||||
步骤:
|
||||
|
||||
1. 启用插件:`openclaw plugins enable google-gemini-cli-auth`
|
||||
1. 启用插件:`openclaw plugins enable google`
|
||||
2. 登录:`openclaw models auth login --provider google-gemini-cli --set-default`
|
||||
|
||||
这会在 Gateway 网关主机上将 OAuth 令牌存储为认证配置文件。详情:[模型提供商](/concepts/model-providers)。
|
||||
|
||||
@ -1,92 +0,0 @@
|
||||
---
|
||||
read_when:
|
||||
- 制作或验证 OpenClaw macOS 发布版本
|
||||
- 更新 Sparkle appcast 或订阅源资源
|
||||
summary: OpenClaw macOS 发布清单(Sparkle 订阅源、打包、签名)
|
||||
title: macOS 发布
|
||||
x-i18n:
|
||||
generated_at: "2026-02-01T21:33:17Z"
|
||||
model: claude-opus-4-5
|
||||
provider: pi
|
||||
source_hash: 703c08c13793cd8c96bd4c31fb4904cdf4ffff35576e7ea48a362560d371cb30
|
||||
source_path: platforms/mac/release.md
|
||||
workflow: 15
|
||||
---
|
||||
|
||||
# OpenClaw macOS 发布(Sparkle)
|
||||
|
||||
本应用现已支持 Sparkle 自动更新。发布构建必须经过 Developer ID 签名、压缩,并发布包含签名的 appcast 条目。
|
||||
|
||||
## 前提条件
|
||||
|
||||
- 已安装 Developer ID Application 证书(示例:`Developer ID Application: <Developer Name> (<TEAMID>)`)。
|
||||
- 环境变量 `SPARKLE_PRIVATE_KEY_FILE` 已设置为 Sparkle ed25519 私钥路径(公钥已嵌入 Info.plist)。如果缺失,请检查 `~/.profile`。
|
||||
- 用于 `xcrun notarytool` 的公证凭据(钥匙串配置文件或 API 密钥),以实现通过 Gatekeeper 安全分发的 DMG/zip。
|
||||
- 我们使用名为 `openclaw-notary` 的钥匙串配置文件,由 shell 配置文件中的 App Store Connect API 密钥环境变量创建:
|
||||
- `APP_STORE_CONNECT_API_KEY_P8`、`APP_STORE_CONNECT_KEY_ID`、`APP_STORE_CONNECT_ISSUER_ID`
|
||||
- `echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/openclaw-notary.p8`
|
||||
- `xcrun notarytool store-credentials "openclaw-notary" --key /tmp/openclaw-notary.p8 --key-id "$APP_STORE_CONNECT_KEY_ID" --issuer "$APP_STORE_CONNECT_ISSUER_ID"`
|
||||
- 已安装 `pnpm` 依赖(`pnpm install --config.node-linker=hoisted`)。
|
||||
- Sparkle 工具通过 SwiftPM 自动获取,位于 `apps/macos/.build/artifacts/sparkle/Sparkle/bin/`(`sign_update`、`generate_appcast` 等)。
|
||||
|
||||
## 构建与打包
|
||||
|
||||
注意事项:
|
||||
|
||||
- `APP_BUILD` 映射到 `CFBundleVersion`/`sparkle:version`;保持纯数字且单调递增(不含 `-beta`),否则 Sparkle 会将其视为相同版本。
|
||||
- 默认为当前架构(`$(uname -m)`)。对于发布/通用构建,设置 `BUILD_ARCHS="arm64 x86_64"`(或 `BUILD_ARCHS=all`)。
|
||||
- 使用 `scripts/package-mac-dist.sh` 生成发布产物(zip + DMG + 公证)。使用 `scripts/package-mac-app.sh` 进行本地/开发打包。
|
||||
|
||||
```bash
|
||||
# 从仓库根目录运行;设置发布 ID 以启用 Sparkle 订阅源。
|
||||
# APP_BUILD 必须为纯数字且单调递增,以便 Sparkle 正确比较。
|
||||
BUNDLE_ID=bot.molt.mac \
|
||||
APP_VERSION=2026.1.27-beta.1 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-app.sh
|
||||
|
||||
# 打包用于分发的 zip(包含资源分支以支持 Sparkle 增量更新)
|
||||
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.1.27-beta.1.zip
|
||||
|
||||
# 可选:同时构建适合用户使用的样式化 DMG(拖拽到 /Applications)
|
||||
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.1.27-beta.1.dmg
|
||||
|
||||
# 推荐:构建 + 公证/装订 zip + DMG
|
||||
# 首先,创建一次钥匙串配置文件:
|
||||
# xcrun notarytool store-credentials "openclaw-notary" \
|
||||
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
|
||||
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
|
||||
BUNDLE_ID=bot.molt.mac \
|
||||
APP_VERSION=2026.1.27-beta.1 \
|
||||
APP_BUILD="$(git rev-list --count HEAD)" \
|
||||
BUILD_CONFIG=release \
|
||||
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
|
||||
scripts/package-mac-dist.sh
|
||||
|
||||
# 可选:随发布一起提供 dSYM
|
||||
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.1.27-beta.1.dSYM.zip
|
||||
```
|
||||
|
||||
## Appcast 条目
|
||||
|
||||
使用发布说明生成器,以便 Sparkle 渲染格式化的 HTML 说明:
|
||||
|
||||
```bash
|
||||
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.1.27-beta.1.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
|
||||
```
|
||||
|
||||
从 `CHANGELOG.md`(通过 [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh))生成 HTML 发布说明,并将其嵌入 appcast 条目。
|
||||
发布时,将更新后的 `appcast.xml` 与发布资源(zip + dSYM)一起提交。
|
||||
|
||||
## 发布与验证
|
||||
|
||||
- 将 `OpenClaw-2026.1.27-beta.1.zip`(和 `OpenClaw-2026.1.27-beta.1.dSYM.zip`)上传到标签 `v2026.1.27-beta.1` 对应的 GitHub 发布。
|
||||
- 确保原始 appcast URL 与内置的订阅源匹配:`https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`。
|
||||
- 完整性检查:
|
||||
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` 返回 200。
|
||||
- `curl -I <enclosure url>` 在资源上传后返回 200。
|
||||
- 在之前的公开构建版本上,从 About 选项卡运行"Check for Updates…",验证 Sparkle 能正常安装新构建。
|
||||
|
||||
完成定义:已签名的应用 + appcast 已发布,从旧版本的更新流程正常工作,且发布资源已附加到 GitHub 发布。
|
||||
@ -1,123 +1,48 @@
|
||||
---
|
||||
read_when:
|
||||
- 发布新的 npm 版本
|
||||
- 发布新的 macOS 应用版本
|
||||
- 发布前验证元数据
|
||||
summary: npm + macOS 应用的逐步发布清单
|
||||
- 查找公开发布渠道的定义
|
||||
- 查找版本命名与发布节奏
|
||||
summary: 公开发布渠道、版本命名与发布节奏
|
||||
title: 发布策略
|
||||
x-i18n:
|
||||
generated_at: "2026-02-03T10:09:28Z"
|
||||
model: claude-opus-4-5
|
||||
generated_at: "2026-03-15T19:23:11Z"
|
||||
model: claude-opus-4-6
|
||||
provider: pi
|
||||
source_hash: 1a684bc26665966eb3c9c816d58d18eead008fd710041181ece38c21c5ff1c62
|
||||
source_hash: df332d3169de7099661725d9266955456e80fc3d3ff95cb7aaf9997a02f0baaf
|
||||
source_path: reference/RELEASING.md
|
||||
workflow: 15
|
||||
---
|
||||
|
||||
# 发布清单(npm + macOS)
|
||||
# 发布策略
|
||||
|
||||
从仓库根目录使用 `pnpm`(Node 22+)。在打标签/发布前保持工作树干净。
|
||||
OpenClaw 有三个公开发布渠道:
|
||||
|
||||
## 操作员触发
|
||||
- stable:带标签的正式发布,发布到 npm `latest`
|
||||
- beta:预发布标签,发布到 npm `beta`
|
||||
- dev:`main` 分支的最新提交
|
||||
|
||||
当操作员说"release"时,立即执行此预检(除非遇到阻碍否则不要额外提问):
|
||||
## 版本命名
|
||||
|
||||
- 阅读本文档和 `docs/platforms/mac/release.md`。
|
||||
- 从 `~/.profile` 加载环境变量并确认 `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect 变量已设置(SPARKLE_PRIVATE_KEY_FILE 应位于 `~/.profile` 中)。
|
||||
- 如需要,使用 `~/Library/CloudStorage/Dropbox/Backup/Sparkle` 中的 Sparkle 密钥。
|
||||
- 正式发布版本号:`YYYY.M.D`
|
||||
- Git 标签:`vYYYY.M.D`
|
||||
- Beta 预发布版本号:`YYYY.M.D-beta.N`
|
||||
- Git 标签:`vYYYY.M.D-beta.N`
|
||||
- 月份和日期不补零
|
||||
- `latest` 表示当前 npm 正式发布版本
|
||||
- `beta` 表示当前 npm 预发布版本
|
||||
- Beta 版本可能会在 macOS 应用跟进之前发布
|
||||
|
||||
1. **版本和元数据**
|
||||
## 发布节奏
|
||||
|
||||
- [ ] 更新 `package.json` 版本(例如 `2026.1.29`)。
|
||||
- [ ] 运行 `pnpm plugins:sync` 以对齐扩展包版本和变更日志。
|
||||
- [ ] 更新 CLI/版本字符串:[`src/cli/program.ts`](https://github.com/openclaw/openclaw/blob/main/src/cli/program.ts) 和 [`src/provider-web.ts`](https://github.com/openclaw/openclaw/blob/main/src/provider-web.ts) 中的 Baileys user agent。
|
||||
- [ ] 确认包元数据(name、description、repository、keywords、license)以及 `bin` 映射指向 [`openclaw.mjs`](https://github.com/openclaw/openclaw/blob/main/openclaw.mjs) 作为 `openclaw`。
|
||||
- [ ] 如果依赖项有变化,运行 `pnpm install` 确保 `pnpm-lock.yaml` 是最新的。
|
||||
- 发布遵循 beta 优先原则
|
||||
- 仅在最新的 beta 版本验证通过后才会发布正式版本
|
||||
- 详细的发布流程、审批、凭证和恢复说明仅限维护者查阅
|
||||
|
||||
2. **构建和产物**
|
||||
## 公开参考
|
||||
|
||||
- [ ] 如果 A2UI 输入有变化,运行 `pnpm canvas:a2ui:bundle` 并提交更新后的 [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/openclaw/openclaw/blob/main/src/canvas-host/a2ui/a2ui.bundle.js)。
|
||||
- [ ] `pnpm run build`(重新生成 `dist/`)。
|
||||
- [ ] 验证 npm 包的 `files` 包含所有必需的 `dist/*` 文件夹(特别是用于 headless node + ACP CLI 的 `dist/node-host/**` 和 `dist/acp/**`)。
|
||||
- [ ] 确认 `dist/build-info.json` 存在并包含预期的 `commit` 哈希(CLI 横幅在 npm 安装时使用此信息)。
|
||||
- [ ] 可选:构建后运行 `npm pack --pack-destination /tmp`;检查 tarball 内容并保留以备 GitHub 发布使用(**不要**提交它)。
|
||||
- [`.github/workflows/openclaw-npm-release.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-npm-release.yml)
|
||||
- [`scripts/openclaw-npm-release-check.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/openclaw-npm-release-check.ts)
|
||||
|
||||
3. **变更日志和文档**
|
||||
|
||||
- [ ] 更新 `CHANGELOG.md`,添加面向用户的亮点(如果文件不存在则创建);按版本严格降序排列条目。
|
||||
- [ ] 确保 README 示例/标志与当前 CLI 行为匹配(特别是新命令或选项)。
|
||||
|
||||
4. **验证**
|
||||
|
||||
- [ ] `pnpm build`
|
||||
- [ ] `pnpm check`
|
||||
- [ ] `pnpm test`(如需覆盖率输出则使用 `pnpm test:coverage`)
|
||||
- [ ] `pnpm release:check`(验证 npm pack 内容)
|
||||
- [ ] `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke`(Docker 安装冒烟测试,快速路径;发布前必需)
|
||||
- 如果已知上一个 npm 发布版本有问题,为预安装步骤设置 `OPENCLAW_INSTALL_SMOKE_PREVIOUS=<last-good-version>` 或 `OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1`。
|
||||
- [ ](可选)完整安装程序冒烟测试(添加非 root + CLI 覆盖):`pnpm test:install:smoke`
|
||||
- [ ](可选)安装程序 E2E(Docker,运行 `curl -fsSL https://openclaw.ai/install.sh | bash`,新手引导,然后运行真实工具调用):
|
||||
- `pnpm test:install:e2e:openai`(需要 `OPENAI_API_KEY`)
|
||||
- `pnpm test:install:e2e:anthropic`(需要 `ANTHROPIC_API_KEY`)
|
||||
- `pnpm test:install:e2e`(需要两个密钥;运行两个提供商)
|
||||
- [ ](可选)如果你的更改影响发送/接收路径,抽查 Web Gateway 网关。
|
||||
|
||||
5. **macOS 应用(Sparkle)**
|
||||
|
||||
- [ ] 构建并签名 macOS 应用,然后压缩以供分发。
|
||||
- [ ] 生成 Sparkle appcast(通过 [`scripts/make_appcast.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/make_appcast.sh) 生成 HTML 注释)并更新 `appcast.xml`。
|
||||
- [ ] 保留应用 zip(和可选的 dSYM zip)以便附加到 GitHub 发布。
|
||||
- [ ] 按照 [macOS 发布](/platforms/mac/release) 获取确切命令和所需环境变量。
|
||||
- `APP_BUILD` 必须是数字且单调递增(不带 `-beta`),以便 Sparkle 正确比较版本。
|
||||
- 如果进行公证,使用从 App Store Connect API 环境变量创建的 `openclaw-notary` 钥匙串配置文件(参见 [macOS 发布](/platforms/mac/release))。
|
||||
|
||||
6. **发布(npm)**
|
||||
|
||||
- [ ] 确认 git 状态干净;根据需要提交并推送。
|
||||
- [ ] 如需要,`npm login`(验证 2FA)。
|
||||
- [ ] `npm publish --access public`(预发布版本使用 `--tag beta`)。
|
||||
- [ ] 验证注册表:`npm view openclaw version`、`npm view openclaw dist-tags` 和 `npx -y openclaw@X.Y.Z --version`(或 `--help`)。
|
||||
|
||||
### 故障排除(来自 2.0.0-beta2 发布的笔记)
|
||||
|
||||
- **npm pack/publish 挂起或产生巨大 tarball**:`dist/OpenClaw.app` 中的 macOS 应用包(和发布 zip)被扫入包中。通过 `package.json` 的 `files` 白名单发布内容来修复(包含 dist 子目录、docs、skills;排除应用包)。用 `npm pack --dry-run` 确认 `dist/OpenClaw.app` 未列出。
|
||||
- **npm auth dist-tags 的 Web 循环**:使用旧版认证以获取 OTP 提示:
|
||||
- `NPM_CONFIG_AUTH_TYPE=legacy npm dist-tag add openclaw@X.Y.Z latest`
|
||||
- **`npx` 验证失败并显示 `ECOMPROMISED: Lock compromised`**:使用新缓存重试:
|
||||
- `NPM_CONFIG_CACHE=/tmp/npm-cache-$(date +%s) npx -y openclaw@X.Y.Z --version`
|
||||
- **延迟修复后需要重新指向标签**:强制更新并推送标签,然后确保 GitHub 发布资产仍然匹配:
|
||||
- `git tag -f vX.Y.Z && git push -f origin vX.Y.Z`
|
||||
|
||||
7. **GitHub 发布 + appcast**
|
||||
|
||||
- [ ] 打标签并推送:`git tag vX.Y.Z && git push origin vX.Y.Z`(或 `git push --tags`)。
|
||||
- [ ] 为 `vX.Y.Z` 创建/刷新 GitHub 发布,**标题为 `openclaw X.Y.Z`**(不仅仅是标签);正文应包含该版本的**完整**变更日志部分(亮点 + 更改 + 修复),内联显示(无裸链接),且**不得在正文中重复标题**。
|
||||
- [ ] 附加产物:`npm pack` tarball(可选)、`OpenClaw-X.Y.Z.zip` 和 `OpenClaw-X.Y.Z.dSYM.zip`(如果生成)。
|
||||
- [ ] 提交更新后的 `appcast.xml` 并推送(Sparkle 从 main 获取源)。
|
||||
- [ ] 从干净的临时目录(无 `package.json`),运行 `npx -y openclaw@X.Y.Z send --help` 确认安装/CLI 入口点正常工作。
|
||||
- [ ] 宣布/分享发布说明。
|
||||
|
||||
## 插件发布范围(npm)
|
||||
|
||||
我们只发布 `@openclaw/*` 范围下的**现有 npm 插件**。不在 npm 上的内置插件保持**仅磁盘树**(仍在 `extensions/**` 中发布)。
|
||||
|
||||
获取列表的流程:
|
||||
|
||||
1. `npm search @openclaw --json` 并捕获包名。
|
||||
2. 与 `extensions/*/package.json` 名称比较。
|
||||
3. 只发布**交集**(已在 npm 上)。
|
||||
|
||||
当前 npm 插件列表(根据需要更新):
|
||||
|
||||
- @openclaw/bluebubbles
|
||||
- @openclaw/diagnostics-otel
|
||||
- @openclaw/discord
|
||||
- @openclaw/lobster
|
||||
- @openclaw/matrix
|
||||
- @openclaw/msteams
|
||||
- @openclaw/nextcloud-talk
|
||||
- @openclaw/nostr
|
||||
- @openclaw/voice-call
|
||||
- @openclaw/zalo
|
||||
- @openclaw/zalouser
|
||||
|
||||
发布说明还必须标注**默认未启用**的**新可选内置插件**(例如:`tlon`)。
|
||||
维护者使用
|
||||
[`openclaw/maintainers/release/README.md`](https://github.com/openclaw/maintainers/blob/main/release/README.md)
|
||||
中的私有发布文档作为实际操作手册。
|
||||
|
||||
@ -1,20 +1,24 @@
|
||||
---
|
||||
read_when:
|
||||
- 你想要一份完整的文档地图
|
||||
summary: 链接到每篇 OpenClaw 文档的导航中心
|
||||
summary: 链接到所有 OpenClaw 文档的导航中心
|
||||
title: 文档导航中心
|
||||
x-i18n:
|
||||
generated_at: "2026-02-04T17:55:29Z"
|
||||
model: claude-opus-4-5
|
||||
generated_at: "2026-03-15T19:29:16Z"
|
||||
model: claude-opus-4-6
|
||||
provider: pi
|
||||
source_hash: c4b4572b64d36c9690988b8f964b0712f551ee6491b18a493701a17d2d352cb4
|
||||
source_hash: e12e8b7881311fdaf08cd297392911dfa30dc46031a7038b6bb9011d166b1669
|
||||
source_path: start/hubs.md
|
||||
workflow: 15
|
||||
---
|
||||
|
||||
# 文档导航中心
|
||||
|
||||
使用这些导航中心发现每一个页面,包括深入解析和参考文档——它们不一定出现在左侧导航栏中。
|
||||
<Note>
|
||||
如果你是 OpenClaw 新用户,请从[入门指南](/start/getting-started)开始。
|
||||
</Note>
|
||||
|
||||
使用这些导航中心发现每一个页面,包括深入解析和参考文档——它们可能不会出现在左侧导航栏中。
|
||||
|
||||
## 从这里开始
|
||||
|
||||
@ -75,7 +79,6 @@ x-i18n:
|
||||
- [模型提供商中心](/providers/models)
|
||||
- [WhatsApp](/channels/whatsapp)
|
||||
- [Telegram](/channels/telegram)
|
||||
- [Telegram(grammY 注意事项)](/channels/grammy)
|
||||
- [Slack](/channels/slack)
|
||||
- [Discord](/channels/discord)
|
||||
- [Mattermost](/channels/mattermost)(插件)
|
||||
@ -113,17 +116,18 @@ x-i18n:
|
||||
- [OpenProse](/prose)
|
||||
- [CLI 参考](/cli)
|
||||
- [Exec 工具](/tools/exec)
|
||||
- [PDF 工具](/tools/pdf)
|
||||
- [提权模式](/tools/elevated)
|
||||
- [定时任务](/automation/cron-jobs)
|
||||
- [定时任务 vs 心跳](/automation/cron-vs-heartbeat)
|
||||
- [思考 + 详细输出](/tools/thinking)
|
||||
- [模型](/concepts/models)
|
||||
- [子智能体](/tools/subagents)
|
||||
- [Agent send CLI](/tools/agent-send)
|
||||
- [智能体发送 CLI](/tools/agent-send)
|
||||
- [终端界面](/web/tui)
|
||||
- [浏览器控制](/tools/browser)
|
||||
- [浏览器(Linux 故障排除)](/tools/browser-linux-troubleshooting)
|
||||
- [轮询](/automation/poll)
|
||||
- [投票](/automation/poll)
|
||||
|
||||
## 节点、媒体、语音
|
||||
|
||||
@ -160,7 +164,6 @@ x-i18n:
|
||||
- [macOS 权限](/platforms/mac/permissions)
|
||||
- [macOS 远程](/platforms/mac/remote)
|
||||
- [macOS 签名](/platforms/mac/signing)
|
||||
- [macOS 发布](/platforms/mac/release)
|
||||
- [macOS Gateway 网关 (launchd)](/platforms/mac/bundled-gateway)
|
||||
- [macOS XPC](/platforms/mac/xpc)
|
||||
- [macOS Skills](/platforms/mac/skills)
|
||||
@ -183,8 +186,6 @@ x-i18n:
|
||||
## 实验(探索性)
|
||||
|
||||
- [新手引导配置协议](/experiments/onboarding-config-protocol)
|
||||
- [定时任务加固笔记](/experiments/plans/cron-add-hardening)
|
||||
- [群组策略加固笔记](/experiments/plans/group-policy-hardening)
|
||||
- [研究:记忆](/experiments/research/memory)
|
||||
- [模型配置探索](/experiments/proposals/model-config)
|
||||
|
||||
@ -195,5 +196,5 @@ x-i18n:
|
||||
## 测试 + 发布
|
||||
|
||||
- [测试](/reference/test)
|
||||
- [发布检查清单](/reference/RELEASING)
|
||||
- [发布策略](/reference/RELEASING)
|
||||
- [设备型号](/reference/device-models)
|
||||
|
||||
@ -5,10 +5,10 @@ read_when:
|
||||
summary: OpenClaw 插件/扩展:发现、配置和安全
|
||||
title: 插件
|
||||
x-i18n:
|
||||
generated_at: "2026-02-03T07:55:25Z"
|
||||
generated_at: "2026-03-16T01:39:16Z"
|
||||
model: claude-opus-4-5
|
||||
provider: pi
|
||||
source_hash: b36ca6b90ca03eaae25c00f9b12f2717fcd17ac540ba616ee03b398b234c2308
|
||||
source_hash: 3c79de31bf50147bdfa6cfc5ed55185e91bb55a8db986df0596b24d5529c7798
|
||||
source_path: tools/plugin.md
|
||||
workflow: 15
|
||||
---
|
||||
@ -50,8 +50,7 @@ openclaw plugins install @openclaw/voice-call
|
||||
- [Nostr](/channels/nostr) — `@openclaw/nostr`
|
||||
- [Zalo](/channels/zalo) — `@openclaw/zalo`
|
||||
- [Microsoft Teams](/channels/msteams) — `@openclaw/msteams`
|
||||
- Google Antigravity OAuth(提供商认证)— 作为 `google-antigravity-auth` 捆绑(默认禁用)
|
||||
- Gemini CLI OAuth(提供商认证)— 作为 `google-gemini-cli-auth` 捆绑(默认禁用)
|
||||
- Google 网页搜索 + Gemini CLI OAuth — 作为 `google` 捆绑(网页搜索会自动加载;提供商认证仍需手动启用)
|
||||
- Qwen OAuth(提供商认证)— 作为 `qwen-portal-auth` 捆绑(默认禁用)
|
||||
- Copilot Proxy(提供商认证)— 本地 VS Code Copilot Proxy 桥接;与内置 `github-copilot` 设备登录不同(捆绑,默认禁用)
|
||||
|
||||
|
||||
@ -1,13 +1,44 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ACPX_BUNDLED_BIN,
|
||||
ACPX_PINNED_VERSION,
|
||||
createAcpxPluginConfigSchema,
|
||||
resolveAcpxPluginRoot,
|
||||
resolveAcpxPluginConfig,
|
||||
} from "./config.js";
|
||||
|
||||
describe("acpx plugin config parsing", () => {
|
||||
it("resolves source-layout plugin root from a file under src", () => {
|
||||
const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-source-"));
|
||||
try {
|
||||
fs.mkdirSync(path.join(pluginRoot, "src"), { recursive: true });
|
||||
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
|
||||
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
|
||||
|
||||
const moduleUrl = pathToFileURL(path.join(pluginRoot, "src", "config.ts")).href;
|
||||
expect(resolveAcpxPluginRoot(moduleUrl)).toBe(pluginRoot);
|
||||
} finally {
|
||||
fs.rmSync(pluginRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves bundled-layout plugin root from the dist entry file", () => {
|
||||
const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), "acpx-root-dist-"));
|
||||
try {
|
||||
fs.writeFileSync(path.join(pluginRoot, "package.json"), "{}\n", "utf8");
|
||||
fs.writeFileSync(path.join(pluginRoot, "openclaw.plugin.json"), "{}\n", "utf8");
|
||||
|
||||
const moduleUrl = pathToFileURL(path.join(pluginRoot, "index.js")).href;
|
||||
expect(resolveAcpxPluginRoot(moduleUrl)).toBe(pluginRoot);
|
||||
} finally {
|
||||
fs.rmSync(pluginRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves bundled acpx with pinned version by default", () => {
|
||||
const resolved = resolveAcpxPluginConfig({
|
||||
rawConfig: {
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { OpenClawPluginConfigSchema } from "openclaw/plugin-sdk/acpx";
|
||||
@ -11,7 +12,27 @@ export type AcpxNonInteractivePermissionPolicy = (typeof ACPX_NON_INTERACTIVE_PO
|
||||
export const ACPX_PINNED_VERSION = "0.1.16";
|
||||
export const ACPX_VERSION_ANY = "any";
|
||||
const ACPX_BIN_NAME = process.platform === "win32" ? "acpx.cmd" : "acpx";
|
||||
export const ACPX_PLUGIN_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
export function resolveAcpxPluginRoot(moduleUrl: string = import.meta.url): string {
|
||||
let cursor = path.dirname(fileURLToPath(moduleUrl));
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
// Bundled entries live at the plugin root while source files still live under src/.
|
||||
if (
|
||||
fs.existsSync(path.join(cursor, "openclaw.plugin.json")) &&
|
||||
fs.existsSync(path.join(cursor, "package.json"))
|
||||
) {
|
||||
return cursor;
|
||||
}
|
||||
const parent = path.dirname(cursor);
|
||||
if (parent === cursor) {
|
||||
break;
|
||||
}
|
||||
cursor = parent;
|
||||
}
|
||||
return path.resolve(path.dirname(fileURLToPath(moduleUrl)), "..");
|
||||
}
|
||||
|
||||
export const ACPX_PLUGIN_ROOT = resolveAcpxPluginRoot();
|
||||
export const ACPX_BUNDLED_BIN = path.join(ACPX_PLUGIN_ROOT, "node_modules", ".bin", ACPX_BIN_NAME);
|
||||
export function buildAcpxLocalInstallCommand(version: string = ACPX_PINNED_VERSION): string {
|
||||
return `npm install --omit=dev --no-save acpx@${version}`;
|
||||
|
||||
91
extensions/anthropic/index.test.ts
Normal file
91
extensions/anthropic/index.test.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { registerSingleProviderPlugin } from "../../src/test-utils/plugin-registration.js";
|
||||
import {
|
||||
createProviderUsageFetch,
|
||||
makeResponse,
|
||||
} from "../../src/test-utils/provider-usage-fetch.js";
|
||||
import anthropicPlugin from "./index.js";
|
||||
|
||||
const registerProvider = () => registerSingleProviderPlugin(anthropicPlugin);
|
||||
|
||||
describe("anthropic plugin", () => {
|
||||
it("owns anthropic 4.6 forward-compat resolution", () => {
|
||||
const provider = registerProvider();
|
||||
const model = provider.resolveDynamicModel?.({
|
||||
provider: "anthropic",
|
||||
modelId: "claude-sonnet-4.6-20260219",
|
||||
modelRegistry: {
|
||||
find: (_provider: string, id: string) =>
|
||||
id === "claude-sonnet-4.5-20260219"
|
||||
? {
|
||||
id,
|
||||
name: id,
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200_000,
|
||||
maxTokens: 8_192,
|
||||
}
|
||||
: null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "claude-sonnet-4.6-20260219",
|
||||
provider: "anthropic",
|
||||
api: "anthropic-messages",
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("owns usage auth resolution", async () => {
|
||||
const provider = registerProvider();
|
||||
await expect(
|
||||
provider.resolveUsageAuth?.({
|
||||
config: {} as never,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
provider: "anthropic",
|
||||
resolveApiKeyFromConfigAndStore: () => undefined,
|
||||
resolveOAuthToken: async () => ({
|
||||
token: "anthropic-oauth-token",
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
token: "anthropic-oauth-token",
|
||||
});
|
||||
});
|
||||
|
||||
it("owns usage snapshot fetching", async () => {
|
||||
const provider = registerProvider();
|
||||
const mockFetch = createProviderUsageFetch(async (url) => {
|
||||
if (url.includes("api.anthropic.com/api/oauth/usage")) {
|
||||
return makeResponse(200, {
|
||||
five_hour: { utilization: 20, resets_at: "2026-01-07T01:00:00Z" },
|
||||
seven_day: { utilization: 35, resets_at: "2026-01-09T01:00:00Z" },
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const snapshot = await provider.fetchUsageSnapshot?.({
|
||||
config: {} as never,
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
provider: "anthropic",
|
||||
token: "anthropic-oauth-token",
|
||||
timeoutMs: 5_000,
|
||||
fetchFn: mockFetch as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
expect(snapshot).toEqual({
|
||||
provider: "anthropic",
|
||||
displayName: "Claude",
|
||||
windows: [
|
||||
{ label: "5h", usedPercent: 20, resetAt: Date.parse("2026-01-07T01:00:00Z") },
|
||||
{ label: "Week", usedPercent: 35, resetAt: Date.parse("2026-01-09T01:00:00Z") },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
124
extensions/anthropic/index.ts
Normal file
124
extensions/anthropic/index.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import {
|
||||
emptyPluginConfigSchema,
|
||||
type OpenClawPluginApi,
|
||||
type ProviderResolveDynamicModelContext,
|
||||
type ProviderRuntimeModel,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import { normalizeModelCompat } from "../../src/agents/model-compat.js";
|
||||
import { fetchClaudeUsage } from "../../src/infra/provider-usage.fetch.js";
|
||||
|
||||
const PROVIDER_ID = "anthropic";
|
||||
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;
|
||||
const ANTHROPIC_SONNET_46_MODEL_ID = "claude-sonnet-4-6";
|
||||
const ANTHROPIC_SONNET_46_DOT_MODEL_ID = "claude-sonnet-4.6";
|
||||
const ANTHROPIC_SONNET_TEMPLATE_MODEL_IDS = ["claude-sonnet-4-5", "claude-sonnet-4.5"] as const;
|
||||
|
||||
function cloneFirstTemplateModel(params: {
|
||||
modelId: string;
|
||||
templateIds: readonly string[];
|
||||
ctx: ProviderResolveDynamicModelContext;
|
||||
}): ProviderRuntimeModel | undefined {
|
||||
const trimmedModelId = params.modelId.trim();
|
||||
for (const templateId of [...new Set(params.templateIds)].filter(Boolean)) {
|
||||
const template = params.ctx.modelRegistry.find(
|
||||
PROVIDER_ID,
|
||||
templateId,
|
||||
) as ProviderRuntimeModel | null;
|
||||
if (!template) {
|
||||
continue;
|
||||
}
|
||||
return normalizeModelCompat({
|
||||
...template,
|
||||
id: trimmedModelId,
|
||||
name: trimmedModelId,
|
||||
} as ProviderRuntimeModel);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveAnthropic46ForwardCompatModel(params: {
|
||||
ctx: ProviderResolveDynamicModelContext;
|
||||
dashModelId: string;
|
||||
dotModelId: string;
|
||||
dashTemplateId: string;
|
||||
dotTemplateId: string;
|
||||
fallbackTemplateIds: readonly string[];
|
||||
}): ProviderRuntimeModel | undefined {
|
||||
const trimmedModelId = params.ctx.modelId.trim();
|
||||
const lower = trimmedModelId.toLowerCase();
|
||||
const is46Model =
|
||||
lower === params.dashModelId ||
|
||||
lower === params.dotModelId ||
|
||||
lower.startsWith(`${params.dashModelId}-`) ||
|
||||
lower.startsWith(`${params.dotModelId}-`);
|
||||
if (!is46Model) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const templateIds: string[] = [];
|
||||
if (lower.startsWith(params.dashModelId)) {
|
||||
templateIds.push(lower.replace(params.dashModelId, params.dashTemplateId));
|
||||
}
|
||||
if (lower.startsWith(params.dotModelId)) {
|
||||
templateIds.push(lower.replace(params.dotModelId, params.dotTemplateId));
|
||||
}
|
||||
templateIds.push(...params.fallbackTemplateIds);
|
||||
|
||||
return cloneFirstTemplateModel({
|
||||
modelId: trimmedModelId,
|
||||
templateIds,
|
||||
ctx: params.ctx,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAnthropicForwardCompatModel(
|
||||
ctx: ProviderResolveDynamicModelContext,
|
||||
): ProviderRuntimeModel | undefined {
|
||||
return (
|
||||
resolveAnthropic46ForwardCompatModel({
|
||||
ctx,
|
||||
dashModelId: ANTHROPIC_OPUS_46_MODEL_ID,
|
||||
dotModelId: ANTHROPIC_OPUS_46_DOT_MODEL_ID,
|
||||
dashTemplateId: "claude-opus-4-5",
|
||||
dotTemplateId: "claude-opus-4.5",
|
||||
fallbackTemplateIds: ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS,
|
||||
}) ??
|
||||
resolveAnthropic46ForwardCompatModel({
|
||||
ctx,
|
||||
dashModelId: ANTHROPIC_SONNET_46_MODEL_ID,
|
||||
dotModelId: ANTHROPIC_SONNET_46_DOT_MODEL_ID,
|
||||
dashTemplateId: "claude-sonnet-4-5",
|
||||
dotTemplateId: "claude-sonnet-4.5",
|
||||
fallbackTemplateIds: ANTHROPIC_SONNET_TEMPLATE_MODEL_IDS,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const anthropicPlugin = {
|
||||
id: PROVIDER_ID,
|
||||
name: "Anthropic Provider",
|
||||
description: "Bundled Anthropic provider plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: "Anthropic",
|
||||
docsPath: "/providers/models",
|
||||
envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
|
||||
auth: [],
|
||||
resolveDynamicModel: (ctx) => resolveAnthropicForwardCompatModel(ctx),
|
||||
capabilities: {
|
||||
providerFamily: "anthropic",
|
||||
dropThinkingBlockModelHints: ["claude"],
|
||||
},
|
||||
resolveUsageAuth: async (ctx) => await ctx.resolveOAuthToken(),
|
||||
fetchUsageSnapshot: async (ctx) =>
|
||||
await fetchClaudeUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn),
|
||||
isCacheTtlEligible: () => true,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default anthropicPlugin;
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "minimax-portal-auth",
|
||||
"providers": ["minimax-portal"],
|
||||
"id": "anthropic",
|
||||
"providers": ["anthropic"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@openclaw/google-gemini-cli-auth",
|
||||
"name": "@openclaw/anthropic-provider",
|
||||
"version": "2026.3.14",
|
||||
"private": true,
|
||||
"description": "OpenClaw Gemini CLI OAuth provider plugin",
|
||||
"description": "OpenClaw Anthropic provider plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
@ -10,6 +10,7 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "bluebubbles",
|
||||
"label": "BlueBubbles",
|
||||
|
||||
5
extensions/bluebubbles/setup-entry.ts
Normal file
5
extensions/bluebubbles/setup-entry.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { bluebubblesPlugin } from "./src/channel.js";
|
||||
|
||||
export default {
|
||||
plugin: bluebubblesPlugin,
|
||||
};
|
||||
@ -1,18 +1,11 @@
|
||||
import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelPlugin,
|
||||
OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { ChannelAccountSnapshot, ChannelPlugin } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildChannelConfigSchema,
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildProbeChannelStatusSummary,
|
||||
collectBlueBubblesStatusIssues,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveBlueBubblesGroupRequireMention,
|
||||
resolveBlueBubblesGroupToolPolicy,
|
||||
@ -32,14 +25,14 @@ import {
|
||||
resolveDefaultBlueBubblesAccountId,
|
||||
} from "./accounts.js";
|
||||
import { bluebubblesMessageActions } from "./actions.js";
|
||||
import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
|
||||
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||
import { sendBlueBubblesMedia } from "./media-send.js";
|
||||
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
|
||||
import { blueBubblesOnboardingAdapter } from "./onboarding.js";
|
||||
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
|
||||
import { sendMessageBlueBubbles } from "./send.js";
|
||||
import { blueBubblesSetupAdapter } from "./setup-core.js";
|
||||
import { blueBubblesSetupWizard } from "./setup-surface.js";
|
||||
import {
|
||||
extractHandleFromChatGuid,
|
||||
looksLikeBlueBubblesTargetId,
|
||||
@ -88,7 +81,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
},
|
||||
reload: { configPrefixes: ["channels.bluebubbles"] },
|
||||
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
|
||||
onboarding: blueBubblesOnboardingAdapter,
|
||||
setupWizard: blueBubblesSetupWizard,
|
||||
config: {
|
||||
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg: cfg, accountId }),
|
||||
@ -223,53 +216,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
return display?.trim() || target?.trim() || "";
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg: cfg,
|
||||
channelKey: "bluebubbles",
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: ({ input }) => {
|
||||
if (!input.httpUrl && !input.password) {
|
||||
return "BlueBubbles requires --http-url and --password.";
|
||||
}
|
||||
if (!input.httpUrl) {
|
||||
return "BlueBubbles requires --http-url.";
|
||||
}
|
||||
if (!input.password) {
|
||||
return "BlueBubbles requires --password.";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg: cfg,
|
||||
channelKey: "bluebubbles",
|
||||
accountId,
|
||||
name: input.name,
|
||||
});
|
||||
const next =
|
||||
accountId !== DEFAULT_ACCOUNT_ID
|
||||
? migrateBaseNameToDefaultAccount({
|
||||
cfg: namedConfig,
|
||||
channelKey: "bluebubbles",
|
||||
})
|
||||
: namedConfig;
|
||||
return applyBlueBubblesConnectionConfig({
|
||||
cfg: next,
|
||||
accountId,
|
||||
patch: {
|
||||
serverUrl: input.httpUrl,
|
||||
password: input.password,
|
||||
webhookPath: input.webhookPath,
|
||||
},
|
||||
onlyDefinedFields: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
setup: blueBubblesSetupAdapter,
|
||||
pairing: {
|
||||
idLabel: "bluebubblesSenderId",
|
||||
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
|
||||
|
||||
@ -1,89 +0,0 @@
|
||||
import type { WizardPrompter } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/bluebubbles", () => ({
|
||||
DEFAULT_ACCOUNT_ID: "default",
|
||||
addWildcardAllowFrom: vi.fn(),
|
||||
formatDocsLink: (_url: string, fallback: string) => fallback,
|
||||
hasConfiguredSecretInput: (value: unknown) => {
|
||||
if (typeof value === "string") {
|
||||
return value.trim().length > 0;
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return false;
|
||||
}
|
||||
const ref = value as { source?: unknown; provider?: unknown; id?: unknown };
|
||||
const validSource = ref.source === "env" || ref.source === "file" || ref.source === "exec";
|
||||
return (
|
||||
validSource &&
|
||||
typeof ref.provider === "string" &&
|
||||
ref.provider.trim().length > 0 &&
|
||||
typeof ref.id === "string" &&
|
||||
ref.id.trim().length > 0
|
||||
);
|
||||
},
|
||||
mergeAllowFromEntries: (_existing: unknown, entries: string[]) => entries,
|
||||
createAccountListHelpers: () => ({
|
||||
listAccountIds: () => ["default"],
|
||||
resolveDefaultAccountId: () => "default",
|
||||
}),
|
||||
normalizeSecretInputString: (value: unknown) => {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
},
|
||||
normalizeAccountId: (value?: string | null) =>
|
||||
value && value.trim().length > 0 ? value : "default",
|
||||
promptAccountId: vi.fn(),
|
||||
resolveAccountIdForConfigure: async (params: {
|
||||
accountOverride?: string;
|
||||
defaultAccountId: string;
|
||||
}) => params.accountOverride?.trim() || params.defaultAccountId,
|
||||
}));
|
||||
|
||||
describe("bluebubbles onboarding SecretInput", () => {
|
||||
it("preserves existing password SecretRef when user keeps current credential", async () => {
|
||||
const { blueBubblesOnboardingAdapter } = await import("./onboarding.js");
|
||||
type ConfigureContext = Parameters<
|
||||
NonNullable<typeof blueBubblesOnboardingAdapter.configure>
|
||||
>[0];
|
||||
const passwordRef = { source: "env", provider: "default", id: "BLUEBUBBLES_PASSWORD" };
|
||||
const confirm = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(true) // keep server URL
|
||||
.mockResolvedValueOnce(true) // keep password SecretRef
|
||||
.mockResolvedValueOnce(false); // keep default webhook path
|
||||
const text = vi.fn();
|
||||
const note = vi.fn();
|
||||
|
||||
const prompter = {
|
||||
confirm,
|
||||
text,
|
||||
note,
|
||||
} as unknown as WizardPrompter;
|
||||
|
||||
const context = {
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: passwordRef,
|
||||
},
|
||||
},
|
||||
},
|
||||
prompter,
|
||||
runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"],
|
||||
forceAllowFrom: false,
|
||||
accountOverrides: {},
|
||||
shouldPromptAccountIds: false,
|
||||
} satisfies ConfigureContext;
|
||||
|
||||
const result = await blueBubblesOnboardingAdapter.configure(context);
|
||||
|
||||
expect(result.cfg.channels?.bluebubbles?.password).toEqual(passwordRef);
|
||||
expect(text).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -1,289 +0,0 @@
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
OpenClawConfig,
|
||||
DmPolicy,
|
||||
WizardPrompter,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatDocsLink,
|
||||
mergeAllowFromEntries,
|
||||
normalizeAccountId,
|
||||
patchScopedAccountConfig,
|
||||
resolveAccountIdForConfigure,
|
||||
setTopLevelChannelDmPolicyWithAllowFrom,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
import {
|
||||
listBlueBubblesAccountIds,
|
||||
resolveBlueBubblesAccount,
|
||||
resolveDefaultBlueBubblesAccountId,
|
||||
} from "./accounts.js";
|
||||
import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
|
||||
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
|
||||
import { parseBlueBubblesAllowTarget } from "./targets.js";
|
||||
import { normalizeBlueBubblesServerUrl } from "./types.js";
|
||||
|
||||
const channel = "bluebubbles" as const;
|
||||
|
||||
function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig {
|
||||
return setTopLevelChannelDmPolicyWithAllowFrom({
|
||||
cfg,
|
||||
channel: "bluebubbles",
|
||||
dmPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
function setBlueBubblesAllowFrom(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
allowFrom: string[],
|
||||
): OpenClawConfig {
|
||||
return patchScopedAccountConfig({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: { allowFrom },
|
||||
ensureChannelEnabled: false,
|
||||
ensureAccountEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
function parseBlueBubblesAllowFromInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function promptBlueBubblesAllowFrom(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const accountId =
|
||||
params.accountId && normalizeAccountId(params.accountId)
|
||||
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
|
||||
: resolveDefaultBlueBubblesAccountId(params.cfg);
|
||||
const resolved = resolveBlueBubblesAccount({ cfg: params.cfg, accountId });
|
||||
const existing = resolved.config.allowFrom ?? [];
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Allowlist BlueBubbles DMs by handle or chat target.",
|
||||
"Examples:",
|
||||
"- +15555550123",
|
||||
"- user@example.com",
|
||||
"- chat_id:123",
|
||||
"- chat_guid:iMessage;-;+15555550123",
|
||||
"Multiple entries: comma- or newline-separated.",
|
||||
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
|
||||
].join("\n"),
|
||||
"BlueBubbles allowlist",
|
||||
);
|
||||
const entry = await params.prompter.text({
|
||||
message: "BlueBubbles allowFrom (handle or chat_id)",
|
||||
placeholder: "+15555550123, user@example.com, chat_id:123",
|
||||
initialValue: existing[0] ? String(existing[0]) : undefined,
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) {
|
||||
return "Required";
|
||||
}
|
||||
const parts = parseBlueBubblesAllowFromInput(raw);
|
||||
for (const part of parts) {
|
||||
if (part === "*") {
|
||||
continue;
|
||||
}
|
||||
const parsed = parseBlueBubblesAllowTarget(part);
|
||||
if (parsed.kind === "handle" && !parsed.handle) {
|
||||
return `Invalid entry: ${part}`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const parts = parseBlueBubblesAllowFromInput(String(entry));
|
||||
const unique = mergeAllowFromEntries(undefined, parts);
|
||||
return setBlueBubblesAllowFrom(params.cfg, accountId, unique);
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "BlueBubbles",
|
||||
channel,
|
||||
policyKey: "channels.bluebubbles.dmPolicy",
|
||||
allowFromKey: "channels.bluebubbles.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.bluebubbles?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setBlueBubblesDmPolicy(cfg, policy),
|
||||
promptAllowFrom: promptBlueBubblesAllowFrom,
|
||||
};
|
||||
|
||||
export const blueBubblesOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listBlueBubblesAccountIds(cfg).some((accountId) => {
|
||||
const account = resolveBlueBubblesAccount({ cfg, accountId });
|
||||
return account.configured;
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`BlueBubbles: ${configured ? "configured" : "needs setup"}`],
|
||||
selectionHint: configured ? "configured" : "iMessage via BlueBubbles app",
|
||||
quickstartScore: configured ? 1 : 0,
|
||||
};
|
||||
},
|
||||
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
|
||||
const defaultAccountId = resolveDefaultBlueBubblesAccountId(cfg);
|
||||
const accountId = await resolveAccountIdForConfigure({
|
||||
cfg,
|
||||
prompter,
|
||||
label: "BlueBubbles",
|
||||
accountOverride: accountOverrides.bluebubbles,
|
||||
shouldPromptAccountIds,
|
||||
listAccountIds: listBlueBubblesAccountIds,
|
||||
defaultAccountId,
|
||||
});
|
||||
|
||||
let next = cfg;
|
||||
const resolvedAccount = resolveBlueBubblesAccount({ cfg: next, accountId });
|
||||
const validateServerUrlInput = (value: unknown): string | undefined => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return "Required";
|
||||
}
|
||||
try {
|
||||
const normalized = normalizeBlueBubblesServerUrl(trimmed);
|
||||
new URL(normalized);
|
||||
return undefined;
|
||||
} catch {
|
||||
return "Invalid URL format";
|
||||
}
|
||||
};
|
||||
const promptServerUrl = async (initialValue?: string): Promise<string> => {
|
||||
const entered = await prompter.text({
|
||||
message: "BlueBubbles server URL",
|
||||
placeholder: "http://192.168.1.100:1234",
|
||||
initialValue,
|
||||
validate: validateServerUrlInput,
|
||||
});
|
||||
return String(entered).trim();
|
||||
};
|
||||
|
||||
// Prompt for server URL
|
||||
let serverUrl = resolvedAccount.config.serverUrl?.trim();
|
||||
if (!serverUrl) {
|
||||
await prompter.note(
|
||||
[
|
||||
"Enter the BlueBubbles server URL (e.g., http://192.168.1.100:1234).",
|
||||
"Find this in the BlueBubbles Server app under Connection.",
|
||||
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
|
||||
].join("\n"),
|
||||
"BlueBubbles server URL",
|
||||
);
|
||||
serverUrl = await promptServerUrl();
|
||||
} else {
|
||||
const keepUrl = await prompter.confirm({
|
||||
message: `BlueBubbles server URL already set (${serverUrl}). Keep it?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keepUrl) {
|
||||
serverUrl = await promptServerUrl(serverUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt for password
|
||||
const existingPassword = resolvedAccount.config.password;
|
||||
const existingPasswordText = normalizeSecretInputString(existingPassword);
|
||||
const hasConfiguredPassword = hasConfiguredSecretInput(existingPassword);
|
||||
let password: unknown = existingPasswordText;
|
||||
if (!hasConfiguredPassword) {
|
||||
await prompter.note(
|
||||
[
|
||||
"Enter the BlueBubbles server password.",
|
||||
"Find this in the BlueBubbles Server app under Settings.",
|
||||
].join("\n"),
|
||||
"BlueBubbles password",
|
||||
);
|
||||
const entered = await prompter.text({
|
||||
message: "BlueBubbles password",
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
password = String(entered).trim();
|
||||
} else {
|
||||
const keepPassword = await prompter.confirm({
|
||||
message: "BlueBubbles password already set. Keep it?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!keepPassword) {
|
||||
const entered = await prompter.text({
|
||||
message: "BlueBubbles password",
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
password = String(entered).trim();
|
||||
} else if (!existingPasswordText) {
|
||||
password = existingPassword;
|
||||
}
|
||||
}
|
||||
|
||||
// Prompt for webhook path (optional)
|
||||
const existingWebhookPath = resolvedAccount.config.webhookPath?.trim();
|
||||
const wantsWebhook = await prompter.confirm({
|
||||
message: "Configure a custom webhook path? (default: /bluebubbles-webhook)",
|
||||
initialValue: Boolean(existingWebhookPath && existingWebhookPath !== "/bluebubbles-webhook"),
|
||||
});
|
||||
let webhookPath = "/bluebubbles-webhook";
|
||||
if (wantsWebhook) {
|
||||
const entered = await prompter.text({
|
||||
message: "Webhook path",
|
||||
placeholder: "/bluebubbles-webhook",
|
||||
initialValue: existingWebhookPath || "/bluebubbles-webhook",
|
||||
validate: (value) => {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return "Required";
|
||||
}
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return "Path must start with /";
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
webhookPath = String(entered).trim();
|
||||
}
|
||||
|
||||
// Apply config
|
||||
next = applyBlueBubblesConnectionConfig({
|
||||
cfg: next,
|
||||
accountId,
|
||||
patch: {
|
||||
serverUrl,
|
||||
password,
|
||||
webhookPath,
|
||||
},
|
||||
accountEnabled: "preserve-or-true",
|
||||
});
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
"Configure the webhook URL in BlueBubbles Server:",
|
||||
"1. Open BlueBubbles Server → Settings → Webhooks",
|
||||
"2. Add your OpenClaw gateway URL + webhook path",
|
||||
" Example: https://your-gateway-host:3000/bluebubbles-webhook",
|
||||
"3. Enable the webhook and save",
|
||||
"",
|
||||
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
|
||||
].join("\n"),
|
||||
"BlueBubbles next steps",
|
||||
);
|
||||
|
||||
return { cfg: next, accountId };
|
||||
},
|
||||
dmPolicy,
|
||||
disable: (cfg) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
bluebubbles: { ...cfg.channels?.bluebubbles, enabled: false },
|
||||
},
|
||||
}),
|
||||
};
|
||||
84
extensions/bluebubbles/src/setup-core.ts
Normal file
84
extensions/bluebubbles/src/setup-core.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { setTopLevelChannelDmPolicyWithAllowFrom } from "../../../src/channels/plugins/onboarding/helpers.js";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
patchScopedAccountConfig,
|
||||
} from "../../../src/channels/plugins/setup-helpers.js";
|
||||
import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { DmPolicy } from "../../../src/config/types.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||
import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
|
||||
|
||||
const channel = "bluebubbles" as const;
|
||||
|
||||
export function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig {
|
||||
return setTopLevelChannelDmPolicyWithAllowFrom({
|
||||
cfg,
|
||||
channel,
|
||||
dmPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
export function setBlueBubblesAllowFrom(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
allowFrom: string[],
|
||||
): OpenClawConfig {
|
||||
return patchScopedAccountConfig({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: { allowFrom },
|
||||
ensureChannelEnabled: false,
|
||||
ensureAccountEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
export const blueBubblesSetupAdapter: ChannelSetupAdapter = {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: ({ input }) => {
|
||||
if (!input.httpUrl && !input.password) {
|
||||
return "BlueBubbles requires --http-url and --password.";
|
||||
}
|
||||
if (!input.httpUrl) {
|
||||
return "BlueBubbles requires --http-url.";
|
||||
}
|
||||
if (!input.password) {
|
||||
return "BlueBubbles requires --password.";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
name: input.name,
|
||||
});
|
||||
const next =
|
||||
accountId !== DEFAULT_ACCOUNT_ID
|
||||
? migrateBaseNameToDefaultAccount({
|
||||
cfg: namedConfig,
|
||||
channelKey: channel,
|
||||
})
|
||||
: namedConfig;
|
||||
return applyBlueBubblesConnectionConfig({
|
||||
cfg: next,
|
||||
accountId,
|
||||
patch: {
|
||||
serverUrl: input.httpUrl,
|
||||
password: input.password,
|
||||
webhookPath: input.webhookPath,
|
||||
},
|
||||
onlyDefinedFields: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
154
extensions/bluebubbles/src/setup-surface.test.ts
Normal file
154
extensions/bluebubbles/src/setup-surface.test.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
|
||||
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { DEFAULT_WEBHOOK_PATH } from "./monitor-shared.js";
|
||||
|
||||
async function createBlueBubblesConfigureAdapter() {
|
||||
const { blueBubblesSetupAdapter, blueBubblesSetupWizard } = await import("./setup-surface.js");
|
||||
const plugin = {
|
||||
id: "bluebubbles",
|
||||
meta: {
|
||||
id: "bluebubbles",
|
||||
label: "BlueBubbles",
|
||||
selectionLabel: "BlueBubbles",
|
||||
docsPath: "/channels/bluebubbles",
|
||||
blurb: "iMessage via BlueBubbles",
|
||||
},
|
||||
config: {
|
||||
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
|
||||
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
||||
resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg, accountId }),
|
||||
resolveAllowFrom: ({ cfg, accountId }: { cfg: unknown; accountId: string }) =>
|
||||
resolveBlueBubblesAccount({
|
||||
cfg: cfg as Parameters<typeof resolveBlueBubblesAccount>[0]["cfg"],
|
||||
accountId,
|
||||
}).config.allowFrom ?? [],
|
||||
},
|
||||
setup: blueBubblesSetupAdapter,
|
||||
} as Parameters<typeof buildChannelOnboardingAdapterFromSetupWizard>[0]["plugin"];
|
||||
return buildChannelOnboardingAdapterFromSetupWizard({
|
||||
plugin,
|
||||
wizard: blueBubblesSetupWizard,
|
||||
});
|
||||
}
|
||||
|
||||
describe("bluebubbles setup surface", () => {
|
||||
it("preserves existing password SecretRef and keeps default webhook path", async () => {
|
||||
const adapter = await createBlueBubblesConfigureAdapter();
|
||||
type ConfigureContext = Parameters<NonNullable<typeof adapter.configure>>[0];
|
||||
const passwordRef = { source: "env", provider: "default", id: "BLUEBUBBLES_PASSWORD" };
|
||||
const confirm = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true);
|
||||
const text = vi.fn();
|
||||
const note = vi.fn();
|
||||
|
||||
const prompter = { confirm, text, note } as unknown as WizardPrompter;
|
||||
const context = {
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: passwordRef,
|
||||
},
|
||||
},
|
||||
},
|
||||
prompter,
|
||||
runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"],
|
||||
forceAllowFrom: false,
|
||||
accountOverrides: {},
|
||||
shouldPromptAccountIds: false,
|
||||
} satisfies ConfigureContext;
|
||||
|
||||
const result = await adapter.configure(context);
|
||||
|
||||
expect(result.cfg.channels?.bluebubbles?.password).toEqual(passwordRef);
|
||||
expect(result.cfg.channels?.bluebubbles?.webhookPath).toBe(DEFAULT_WEBHOOK_PATH);
|
||||
expect(text).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies a custom webhook path when requested", async () => {
|
||||
const adapter = await createBlueBubblesConfigureAdapter();
|
||||
type ConfigureContext = Parameters<NonNullable<typeof adapter.configure>>[0];
|
||||
const confirm = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(true);
|
||||
const text = vi.fn().mockResolvedValueOnce("/custom-bluebubbles");
|
||||
const note = vi.fn();
|
||||
|
||||
const prompter = { confirm, text, note } as unknown as WizardPrompter;
|
||||
const context = {
|
||||
cfg: {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
password: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
prompter,
|
||||
runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"],
|
||||
forceAllowFrom: false,
|
||||
accountOverrides: {},
|
||||
shouldPromptAccountIds: false,
|
||||
} satisfies ConfigureContext;
|
||||
|
||||
const result = await adapter.configure(context);
|
||||
|
||||
expect(result.cfg.channels?.bluebubbles?.webhookPath).toBe("/custom-bluebubbles");
|
||||
expect(text).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Webhook path",
|
||||
placeholder: DEFAULT_WEBHOOK_PATH,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("validates server URLs before accepting input", async () => {
|
||||
const adapter = await createBlueBubblesConfigureAdapter();
|
||||
type ConfigureContext = Parameters<NonNullable<typeof adapter.configure>>[0];
|
||||
const confirm = vi.fn().mockResolvedValueOnce(false);
|
||||
const text = vi.fn().mockResolvedValueOnce("127.0.0.1:1234").mockResolvedValueOnce("secret");
|
||||
const note = vi.fn();
|
||||
|
||||
const prompter = { confirm, text, note } as unknown as WizardPrompter;
|
||||
const context = {
|
||||
cfg: { channels: { bluebubbles: {} } },
|
||||
prompter,
|
||||
runtime: { ...console, exit: vi.fn() } as ConfigureContext["runtime"],
|
||||
forceAllowFrom: false,
|
||||
accountOverrides: {},
|
||||
shouldPromptAccountIds: false,
|
||||
} satisfies ConfigureContext;
|
||||
|
||||
await adapter.configure(context);
|
||||
|
||||
const serverUrlPrompt = text.mock.calls[0]?.[0] as {
|
||||
validate?: (value: string) => string | undefined;
|
||||
};
|
||||
expect(serverUrlPrompt.validate?.("bad url")).toBe("Invalid URL format");
|
||||
expect(serverUrlPrompt.validate?.("127.0.0.1:1234")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("disables the channel through the setup wizard", async () => {
|
||||
const { blueBubblesSetupWizard } = await import("./setup-surface.js");
|
||||
const next = blueBubblesSetupWizard.disable?.({
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
enabled: true,
|
||||
serverUrl: "http://127.0.0.1:1234",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(next?.channels?.bluebubbles?.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
314
extensions/bluebubbles/src/setup-surface.ts
Normal file
314
extensions/bluebubbles/src/setup-surface.ts
Normal file
@ -0,0 +1,314 @@
|
||||
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
|
||||
import {
|
||||
mergeAllowFromEntries,
|
||||
resolveOnboardingAccountId,
|
||||
} from "../../../src/channels/plugins/onboarding/helpers.js";
|
||||
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { DmPolicy } from "../../../src/config/types.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
|
||||
import { formatDocsLink } from "../../../src/terminal/links.js";
|
||||
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
|
||||
import {
|
||||
listBlueBubblesAccountIds,
|
||||
resolveBlueBubblesAccount,
|
||||
resolveDefaultBlueBubblesAccountId,
|
||||
} from "./accounts.js";
|
||||
import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
|
||||
import { DEFAULT_WEBHOOK_PATH } from "./monitor-shared.js";
|
||||
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
|
||||
import {
|
||||
blueBubblesSetupAdapter,
|
||||
setBlueBubblesAllowFrom,
|
||||
setBlueBubblesDmPolicy,
|
||||
} from "./setup-core.js";
|
||||
import { parseBlueBubblesAllowTarget } from "./targets.js";
|
||||
import { normalizeBlueBubblesServerUrl } from "./types.js";
|
||||
|
||||
const channel = "bluebubbles" as const;
|
||||
const CONFIGURE_CUSTOM_WEBHOOK_FLAG = "__bluebubblesConfigureCustomWebhookPath";
|
||||
|
||||
function parseBlueBubblesAllowFromInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function validateBlueBubblesAllowFromEntry(value: string): string | null {
|
||||
try {
|
||||
if (value === "*") {
|
||||
return value;
|
||||
}
|
||||
const parsed = parseBlueBubblesAllowTarget(value);
|
||||
if (parsed.kind === "handle" && !parsed.handle) {
|
||||
return null;
|
||||
}
|
||||
return value.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function promptBlueBubblesAllowFrom(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const accountId = resolveOnboardingAccountId({
|
||||
accountId: params.accountId,
|
||||
defaultAccountId: resolveDefaultBlueBubblesAccountId(params.cfg),
|
||||
});
|
||||
const resolved = resolveBlueBubblesAccount({ cfg: params.cfg, accountId });
|
||||
const existing = resolved.config.allowFrom ?? [];
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Allowlist BlueBubbles DMs by handle or chat target.",
|
||||
"Examples:",
|
||||
"- +15555550123",
|
||||
"- user@example.com",
|
||||
"- chat_id:123",
|
||||
"- chat_guid:iMessage;-;+15555550123",
|
||||
"Multiple entries: comma- or newline-separated.",
|
||||
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
|
||||
].join("\n"),
|
||||
"BlueBubbles allowlist",
|
||||
);
|
||||
const entry = await params.prompter.text({
|
||||
message: "BlueBubbles allowFrom (handle or chat_id)",
|
||||
placeholder: "+15555550123, user@example.com, chat_id:123",
|
||||
initialValue: existing[0] ? String(existing[0]) : undefined,
|
||||
validate: (value) => {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) {
|
||||
return "Required";
|
||||
}
|
||||
const parts = parseBlueBubblesAllowFromInput(raw);
|
||||
for (const part of parts) {
|
||||
if (!validateBlueBubblesAllowFromEntry(part)) {
|
||||
return `Invalid entry: ${part}`;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const parts = parseBlueBubblesAllowFromInput(String(entry));
|
||||
const unique = mergeAllowFromEntries(undefined, parts);
|
||||
return setBlueBubblesAllowFrom(params.cfg, accountId, unique);
|
||||
}
|
||||
|
||||
function validateBlueBubblesServerUrlInput(value: unknown): string | undefined {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return "Required";
|
||||
}
|
||||
try {
|
||||
const normalized = normalizeBlueBubblesServerUrl(trimmed);
|
||||
new URL(normalized);
|
||||
return undefined;
|
||||
} catch {
|
||||
return "Invalid URL format";
|
||||
}
|
||||
}
|
||||
|
||||
function applyBlueBubblesSetupPatch(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
patch: {
|
||||
serverUrl?: string;
|
||||
password?: unknown;
|
||||
webhookPath?: string;
|
||||
},
|
||||
): OpenClawConfig {
|
||||
return applyBlueBubblesConnectionConfig({
|
||||
cfg,
|
||||
accountId,
|
||||
patch,
|
||||
onlyDefinedFields: true,
|
||||
accountEnabled: "preserve-or-true",
|
||||
});
|
||||
}
|
||||
|
||||
function resolveBlueBubblesServerUrl(cfg: OpenClawConfig, accountId: string): string | undefined {
|
||||
return resolveBlueBubblesAccount({ cfg, accountId }).config.serverUrl?.trim() || undefined;
|
||||
}
|
||||
|
||||
function resolveBlueBubblesWebhookPath(cfg: OpenClawConfig, accountId: string): string | undefined {
|
||||
return resolveBlueBubblesAccount({ cfg, accountId }).config.webhookPath?.trim() || undefined;
|
||||
}
|
||||
|
||||
function validateBlueBubblesWebhookPath(value: string): string | undefined {
|
||||
const trimmed = String(value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return "Required";
|
||||
}
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return "Path must start with /";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "BlueBubbles",
|
||||
channel,
|
||||
policyKey: "channels.bluebubbles.dmPolicy",
|
||||
allowFromKey: "channels.bluebubbles.allowFrom",
|
||||
getCurrent: (cfg) => cfg.channels?.bluebubbles?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setBlueBubblesDmPolicy(cfg, policy),
|
||||
promptAllowFrom: promptBlueBubblesAllowFrom,
|
||||
};
|
||||
|
||||
export const blueBubblesSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
stepOrder: "text-first",
|
||||
status: {
|
||||
configuredLabel: "configured",
|
||||
unconfiguredLabel: "needs setup",
|
||||
configuredHint: "configured",
|
||||
unconfiguredHint: "iMessage via BlueBubbles app",
|
||||
configuredScore: 1,
|
||||
unconfiguredScore: 0,
|
||||
resolveConfigured: ({ cfg }) =>
|
||||
listBlueBubblesAccountIds(cfg).some((accountId) => {
|
||||
const account = resolveBlueBubblesAccount({ cfg, accountId });
|
||||
return account.configured;
|
||||
}),
|
||||
resolveStatusLines: ({ configured }) => [
|
||||
`BlueBubbles: ${configured ? "configured" : "needs setup"}`,
|
||||
],
|
||||
resolveSelectionHint: ({ configured }) =>
|
||||
configured ? "configured" : "iMessage via BlueBubbles app",
|
||||
},
|
||||
prepare: async ({ cfg, accountId, prompter, credentialValues }) => {
|
||||
const existingWebhookPath = resolveBlueBubblesWebhookPath(cfg, accountId);
|
||||
const wantsCustomWebhook = await prompter.confirm({
|
||||
message: `Configure a custom webhook path? (default: ${DEFAULT_WEBHOOK_PATH})`,
|
||||
initialValue: Boolean(existingWebhookPath && existingWebhookPath !== DEFAULT_WEBHOOK_PATH),
|
||||
});
|
||||
return {
|
||||
cfg: wantsCustomWebhook
|
||||
? cfg
|
||||
: applyBlueBubblesSetupPatch(cfg, accountId, { webhookPath: DEFAULT_WEBHOOK_PATH }),
|
||||
credentialValues: {
|
||||
...credentialValues,
|
||||
[CONFIGURE_CUSTOM_WEBHOOK_FLAG]: wantsCustomWebhook ? "1" : "0",
|
||||
},
|
||||
};
|
||||
},
|
||||
credentials: [
|
||||
{
|
||||
inputKey: "password",
|
||||
providerHint: channel,
|
||||
credentialLabel: "server password",
|
||||
helpTitle: "BlueBubbles password",
|
||||
helpLines: [
|
||||
"Enter the BlueBubbles server password.",
|
||||
"Find this in the BlueBubbles Server app under Settings.",
|
||||
],
|
||||
envPrompt: "",
|
||||
keepPrompt: "BlueBubbles password already set. Keep it?",
|
||||
inputPrompt: "BlueBubbles password",
|
||||
inspect: ({ cfg, accountId }) => {
|
||||
const existingPassword = resolveBlueBubblesAccount({ cfg, accountId }).config.password;
|
||||
return {
|
||||
accountConfigured: resolveBlueBubblesAccount({ cfg, accountId }).configured,
|
||||
hasConfiguredValue: hasConfiguredSecretInput(existingPassword),
|
||||
resolvedValue: normalizeSecretInputString(existingPassword) ?? undefined,
|
||||
};
|
||||
},
|
||||
applySet: async ({ cfg, accountId, value }) =>
|
||||
applyBlueBubblesSetupPatch(cfg, accountId, {
|
||||
password: value,
|
||||
}),
|
||||
},
|
||||
],
|
||||
textInputs: [
|
||||
{
|
||||
inputKey: "httpUrl",
|
||||
message: "BlueBubbles server URL",
|
||||
placeholder: "http://192.168.1.100:1234",
|
||||
helpTitle: "BlueBubbles server URL",
|
||||
helpLines: [
|
||||
"Enter the BlueBubbles server URL (e.g., http://192.168.1.100:1234).",
|
||||
"Find this in the BlueBubbles Server app under Connection.",
|
||||
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
|
||||
],
|
||||
currentValue: ({ cfg, accountId }) => resolveBlueBubblesServerUrl(cfg, accountId),
|
||||
validate: ({ value }) => validateBlueBubblesServerUrlInput(value),
|
||||
normalizeValue: ({ value }) => String(value).trim(),
|
||||
applySet: async ({ cfg, accountId, value }) =>
|
||||
applyBlueBubblesSetupPatch(cfg, accountId, {
|
||||
serverUrl: value,
|
||||
}),
|
||||
},
|
||||
{
|
||||
inputKey: "webhookPath",
|
||||
message: "Webhook path",
|
||||
placeholder: DEFAULT_WEBHOOK_PATH,
|
||||
currentValue: ({ cfg, accountId }) => {
|
||||
const value = resolveBlueBubblesWebhookPath(cfg, accountId);
|
||||
return value && value !== DEFAULT_WEBHOOK_PATH ? value : undefined;
|
||||
},
|
||||
shouldPrompt: ({ credentialValues }) =>
|
||||
credentialValues[CONFIGURE_CUSTOM_WEBHOOK_FLAG] === "1",
|
||||
validate: ({ value }) => validateBlueBubblesWebhookPath(value),
|
||||
normalizeValue: ({ value }) => String(value).trim(),
|
||||
applySet: async ({ cfg, accountId, value }) =>
|
||||
applyBlueBubblesSetupPatch(cfg, accountId, {
|
||||
webhookPath: value,
|
||||
}),
|
||||
},
|
||||
],
|
||||
completionNote: {
|
||||
title: "BlueBubbles next steps",
|
||||
lines: [
|
||||
"Configure the webhook URL in BlueBubbles Server:",
|
||||
"1. Open BlueBubbles Server -> Settings -> Webhooks",
|
||||
"2. Add your OpenClaw gateway URL + webhook path",
|
||||
` Example: https://your-gateway-host:3000${DEFAULT_WEBHOOK_PATH}`,
|
||||
"3. Enable the webhook and save",
|
||||
"",
|
||||
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
|
||||
],
|
||||
},
|
||||
dmPolicy,
|
||||
allowFrom: {
|
||||
helpTitle: "BlueBubbles allowlist",
|
||||
helpLines: [
|
||||
"Allowlist BlueBubbles DMs by handle or chat target.",
|
||||
"Examples:",
|
||||
"- +15555550123",
|
||||
"- user@example.com",
|
||||
"- chat_id:123",
|
||||
"- chat_guid:iMessage;-;+15555550123",
|
||||
"Multiple entries: comma- or newline-separated.",
|
||||
`Docs: ${formatDocsLink("/channels/bluebubbles", "bluebubbles")}`,
|
||||
],
|
||||
message: "BlueBubbles allowFrom (handle or chat_id)",
|
||||
placeholder: "+15555550123, user@example.com, chat_id:123",
|
||||
invalidWithoutCredentialNote:
|
||||
"Use a BlueBubbles handle or chat target like +15555550123 or chat_id:123.",
|
||||
parseInputs: parseBlueBubblesAllowFromInput,
|
||||
parseId: (raw) => validateBlueBubblesAllowFromEntry(raw),
|
||||
resolveEntries: async ({ entries }) =>
|
||||
entries.map((entry) => ({
|
||||
input: entry,
|
||||
resolved: Boolean(validateBlueBubblesAllowFromEntry(entry)),
|
||||
id: validateBlueBubblesAllowFromEntry(entry),
|
||||
})),
|
||||
apply: async ({ cfg, accountId, allowFrom }) =>
|
||||
setBlueBubblesAllowFrom(cfg, accountId, allowFrom),
|
||||
},
|
||||
disable: (cfg) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
bluebubbles: {
|
||||
...cfg.channels?.bluebubbles,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export { blueBubblesSetupAdapter };
|
||||
32
extensions/brave/index.ts
Normal file
32
extensions/brave/index.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import {
|
||||
createPluginBackedWebSearchProvider,
|
||||
getTopLevelCredentialValue,
|
||||
setTopLevelCredentialValue,
|
||||
} from "../../src/agents/tools/web-search-plugin-factory.js";
|
||||
import { emptyPluginConfigSchema } from "../../src/plugins/config-schema.js";
|
||||
import type { OpenClawPluginApi } from "../../src/plugins/types.js";
|
||||
|
||||
const bravePlugin = {
|
||||
id: "brave",
|
||||
name: "Brave Plugin",
|
||||
description: "Bundled Brave plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerWebSearchProvider(
|
||||
createPluginBackedWebSearchProvider({
|
||||
id: "brave",
|
||||
label: "Brave Search",
|
||||
hint: "Structured results · country/language/time filters",
|
||||
envVars: ["BRAVE_API_KEY"],
|
||||
placeholder: "BSA...",
|
||||
signupUrl: "https://brave.com/search/api/",
|
||||
docsUrl: "https://docs.openclaw.ai/brave-search",
|
||||
autoDetectOrder: 10,
|
||||
getCredentialValue: getTopLevelCredentialValue,
|
||||
setCredentialValue: setTopLevelCredentialValue,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default bravePlugin;
|
||||
8
extensions/brave/openclaw.plugin.json
Normal file
8
extensions/brave/openclaw.plugin.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "brave",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@openclaw/minimax-portal-auth",
|
||||
"name": "@openclaw/brave-plugin",
|
||||
"version": "2026.3.14",
|
||||
"private": true,
|
||||
"description": "OpenClaw MiniMax Portal OAuth provider plugin",
|
||||
"description": "OpenClaw Brave plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
40
extensions/byteplus/index.ts
Normal file
40
extensions/byteplus/index.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
buildBytePlusCodingProvider,
|
||||
buildBytePlusProvider,
|
||||
} from "../../src/agents/models-config.providers.static.js";
|
||||
|
||||
const PROVIDER_ID = "byteplus";
|
||||
|
||||
const byteplusPlugin = {
|
||||
id: PROVIDER_ID,
|
||||
name: "BytePlus Provider",
|
||||
description: "Bundled BytePlus provider plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: "BytePlus",
|
||||
docsPath: "/concepts/model-providers#byteplus-international",
|
||||
envVars: ["BYTEPLUS_API_KEY"],
|
||||
auth: [],
|
||||
catalog: {
|
||||
order: "paired",
|
||||
run: async (ctx) => {
|
||||
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
providers: {
|
||||
byteplus: { ...buildBytePlusProvider(), apiKey },
|
||||
"byteplus-plan": { ...buildBytePlusCodingProvider(), apiKey },
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default byteplusPlugin;
|
||||
9
extensions/byteplus/openclaw.plugin.json
Normal file
9
extensions/byteplus/openclaw.plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "byteplus",
|
||||
"providers": ["byteplus", "byteplus-plan"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
12
extensions/byteplus/package.json
Normal file
12
extensions/byteplus/package.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/byteplus-provider",
|
||||
"version": "2026.3.14",
|
||||
"private": true,
|
||||
"description": "OpenClaw BytePlus provider plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
82
extensions/cloudflare-ai-gateway/index.ts
Normal file
82
extensions/cloudflare-ai-gateway/index.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import { emptyPluginConfigSchema, type OpenClawPluginApi } from "openclaw/plugin-sdk/core";
|
||||
import { ensureAuthProfileStore, listProfilesForProvider } from "../../src/agents/auth-profiles.js";
|
||||
import {
|
||||
buildCloudflareAiGatewayModelDefinition,
|
||||
resolveCloudflareAiGatewayBaseUrl,
|
||||
} from "../../src/agents/cloudflare-ai-gateway.js";
|
||||
import { resolveNonEnvSecretRefApiKeyMarker } from "../../src/agents/model-auth-markers.js";
|
||||
import { coerceSecretRef } from "../../src/config/types.secrets.js";
|
||||
|
||||
const PROVIDER_ID = "cloudflare-ai-gateway";
|
||||
const PROVIDER_ENV_VAR = "CLOUDFLARE_AI_GATEWAY_API_KEY";
|
||||
|
||||
function resolveApiKeyFromCredential(
|
||||
cred: ReturnType<typeof ensureAuthProfileStore>["profiles"][string] | undefined,
|
||||
): string | undefined {
|
||||
if (!cred || cred.type !== "api_key") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const keyRef = coerceSecretRef(cred.keyRef);
|
||||
if (keyRef && keyRef.id.trim()) {
|
||||
return keyRef.source === "env"
|
||||
? keyRef.id.trim()
|
||||
: resolveNonEnvSecretRefApiKeyMarker(keyRef.source);
|
||||
}
|
||||
return cred.key?.trim() || undefined;
|
||||
}
|
||||
|
||||
const cloudflareAiGatewayPlugin = {
|
||||
id: PROVIDER_ID,
|
||||
name: "Cloudflare AI Gateway Provider",
|
||||
description: "Bundled Cloudflare AI Gateway provider plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: "Cloudflare AI Gateway",
|
||||
docsPath: "/providers/cloudflare-ai-gateway",
|
||||
envVars: ["CLOUDFLARE_AI_GATEWAY_API_KEY"],
|
||||
auth: [],
|
||||
catalog: {
|
||||
order: "late",
|
||||
run: async (ctx) => {
|
||||
const authStore = ensureAuthProfileStore(ctx.agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const envManagedApiKey = ctx.env[PROVIDER_ENV_VAR]?.trim() ? PROVIDER_ENV_VAR : undefined;
|
||||
for (const profileId of listProfilesForProvider(authStore, PROVIDER_ID)) {
|
||||
const cred = authStore.profiles[profileId];
|
||||
if (!cred || cred.type !== "api_key") {
|
||||
continue;
|
||||
}
|
||||
const apiKey = envManagedApiKey ?? resolveApiKeyFromCredential(cred);
|
||||
if (!apiKey) {
|
||||
continue;
|
||||
}
|
||||
const accountId = cred.metadata?.accountId?.trim();
|
||||
const gatewayId = cred.metadata?.gatewayId?.trim();
|
||||
if (!accountId || !gatewayId) {
|
||||
continue;
|
||||
}
|
||||
const baseUrl = resolveCloudflareAiGatewayBaseUrl({ accountId, gatewayId });
|
||||
if (!baseUrl) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
provider: {
|
||||
baseUrl,
|
||||
api: "anthropic-messages",
|
||||
apiKey,
|
||||
models: [buildCloudflareAiGatewayModelDefinition()],
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default cloudflareAiGatewayPlugin;
|
||||
9
extensions/cloudflare-ai-gateway/openclaw.plugin.json
Normal file
9
extensions/cloudflare-ai-gateway/openclaw.plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "cloudflare-ai-gateway",
|
||||
"providers": ["cloudflare-ai-gateway"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
12
extensions/cloudflare-ai-gateway/package.json
Normal file
12
extensions/cloudflare-ai-gateway/package.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/cloudflare-ai-gateway-provider",
|
||||
"version": "2026.3.14",
|
||||
"private": true,
|
||||
"description": "OpenClaw Cloudflare AI Gateway provider plugin",
|
||||
"type": "module",
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,9 @@ const plugin = {
|
||||
register(api: OpenClawPluginApi) {
|
||||
setDiscordRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: discordPlugin });
|
||||
if (api.registrationMode !== "full") {
|
||||
return;
|
||||
}
|
||||
registerDiscordSubagentHooks(api);
|
||||
},
|
||||
};
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts"
|
||||
}
|
||||
}
|
||||
|
||||
3
extensions/discord/setup-entry.ts
Normal file
3
extensions/discord/setup-entry.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { discordPlugin } from "./src/channel.js";
|
||||
|
||||
export default { plugin: discordPlugin };
|
||||
@ -1,10 +1,13 @@
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { DiscordAccountConfig } from "../../../src/config/types.discord.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
type OpenClawConfig,
|
||||
type DiscordAccountConfig,
|
||||
} from "openclaw/plugin-sdk/discord";
|
||||
import {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeSecretInputString,
|
||||
} from "../../../src/config/types.secrets.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||
import {
|
||||
mergeDiscordAccountConfig,
|
||||
resolveDefaultDiscordAccountId,
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import type {
|
||||
OpenClawConfig,
|
||||
DiscordAccountConfig,
|
||||
DiscordActionConfig,
|
||||
} from "openclaw/plugin-sdk/discord";
|
||||
import { createAccountActionGate } from "../../../src/channels/plugins/account-action-gate.js";
|
||||
import { createAccountListHelpers } from "../../../src/channels/plugins/account-helpers.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { DiscordAccountConfig, DiscordActionConfig } from "../../../src/config/types.js";
|
||||
import { resolveAccountEntry } from "../../../src/routing/account-lookup.js";
|
||||
import { normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||
import { resolveDiscordToken } from "./token.js";
|
||||
|
||||
1
extensions/discord/src/channel.runtime.ts
Normal file
1
extensions/discord/src/channel.runtime.ts
Normal file
@ -0,0 +1 @@
|
||||
export { discordSetupWizard } from "./setup-surface.js";
|
||||
@ -7,14 +7,12 @@ import {
|
||||
formatAllowFromLowercase,
|
||||
} from "openclaw/plugin-sdk/compat";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildChannelConfigSchema,
|
||||
buildTokenChannelStatusSummary,
|
||||
collectDiscordAuditChannelIds,
|
||||
collectDiscordStatusIssues,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
discordOnboardingAdapter,
|
||||
DiscordConfigSchema,
|
||||
getChatChannelMeta,
|
||||
inspectDiscordAccount,
|
||||
@ -22,8 +20,6 @@ import {
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
looksLikeDiscordTargetId,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
normalizeDiscordMessagingTarget,
|
||||
normalizeDiscordOutboundTarget,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
@ -39,6 +35,7 @@ import {
|
||||
} from "openclaw/plugin-sdk/discord";
|
||||
import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js";
|
||||
import { getDiscordRuntime } from "./runtime.js";
|
||||
import { createDiscordSetupWizardProxy, discordSetupAdapter } from "./setup-core.js";
|
||||
|
||||
type DiscordSendFn = ReturnType<
|
||||
typeof getDiscordRuntime
|
||||
@ -46,6 +43,10 @@ type DiscordSendFn = ReturnType<
|
||||
|
||||
const meta = getChatChannelMeta("discord");
|
||||
|
||||
async function loadDiscordChannelRuntime() {
|
||||
return await import("./channel.runtime.js");
|
||||
}
|
||||
|
||||
const discordMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: (ctx) =>
|
||||
getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [],
|
||||
@ -76,12 +77,16 @@ const discordConfigBase = createScopedChannelConfigBase({
|
||||
clearBaseFields: ["token", "name"],
|
||||
});
|
||||
|
||||
const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({
|
||||
discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard,
|
||||
}));
|
||||
|
||||
export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
id: "discord",
|
||||
meta: {
|
||||
...meta,
|
||||
},
|
||||
onboarding: discordOnboardingAdapter,
|
||||
setupWizard: discordSetupWizard,
|
||||
pairing: {
|
||||
idLabel: "discordUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(discord|user):/i, ""),
|
||||
@ -233,71 +238,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
},
|
||||
},
|
||||
actions: discordMessageActions,
|
||||
setup: {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: "discord",
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: ({ accountId, input }) => {
|
||||
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return "DISCORD_BOT_TOKEN can only be used for the default account.";
|
||||
}
|
||||
if (!input.useEnv && !input.token) {
|
||||
return "Discord requires token (or --use-env).";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: "discord",
|
||||
accountId,
|
||||
name: input.name,
|
||||
});
|
||||
const next =
|
||||
accountId !== DEFAULT_ACCOUNT_ID
|
||||
? migrateBaseNameToDefaultAccount({
|
||||
cfg: namedConfig,
|
||||
channelKey: "discord",
|
||||
})
|
||||
: namedConfig;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
discord: {
|
||||
...next.channels?.discord,
|
||||
enabled: true,
|
||||
...(input.useEnv ? {} : input.token ? { token: input.token } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
discord: {
|
||||
...next.channels?.discord,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.channels?.discord?.accounts,
|
||||
[accountId]: {
|
||||
...next.channels?.discord?.accounts?.[accountId],
|
||||
enabled: true,
|
||||
...(input.token ? { token: input.token } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
setup: discordSetupAdapter,
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: null,
|
||||
|
||||
@ -19,11 +19,13 @@ describe("discord components", () => {
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
buttons: [{ label: "Approve", style: "success" }],
|
||||
buttons: [{ label: "Approve", style: "success", callbackData: "codex:approve" }],
|
||||
},
|
||||
],
|
||||
modal: {
|
||||
title: "Details",
|
||||
callbackData: "codex:modal",
|
||||
allowedUsers: ["discord:user-1"],
|
||||
fields: [{ type: "text", label: "Requester" }],
|
||||
},
|
||||
});
|
||||
@ -39,6 +41,11 @@ describe("discord components", () => {
|
||||
|
||||
const trigger = result.entries.find((entry) => entry.kind === "modal-trigger");
|
||||
expect(trigger?.modalId).toBe(result.modals[0]?.id);
|
||||
expect(result.entries.find((entry) => entry.kind === "button")?.callbackData).toBe(
|
||||
"codex:approve",
|
||||
);
|
||||
expect(result.modals[0]?.callbackData).toBe("codex:modal");
|
||||
expect(result.modals[0]?.allowedUsers).toEqual(["discord:user-1"]);
|
||||
});
|
||||
|
||||
it("requires options for modal select fields", () => {
|
||||
|
||||
@ -46,6 +46,7 @@ export type DiscordComponentButtonSpec = {
|
||||
label: string;
|
||||
style?: DiscordComponentButtonStyle;
|
||||
url?: string;
|
||||
callbackData?: string;
|
||||
emoji?: {
|
||||
name: string;
|
||||
id?: string;
|
||||
@ -70,10 +71,12 @@ export type DiscordComponentSelectOption = {
|
||||
|
||||
export type DiscordComponentSelectSpec = {
|
||||
type?: DiscordComponentSelectType;
|
||||
callbackData?: string;
|
||||
placeholder?: string;
|
||||
minValues?: number;
|
||||
maxValues?: number;
|
||||
options?: DiscordComponentSelectOption[];
|
||||
allowedUsers?: string[];
|
||||
};
|
||||
|
||||
export type DiscordComponentSectionAccessory =
|
||||
@ -136,8 +139,10 @@ export type DiscordModalFieldSpec = {
|
||||
|
||||
export type DiscordModalSpec = {
|
||||
title: string;
|
||||
callbackData?: string;
|
||||
triggerLabel?: string;
|
||||
triggerStyle?: DiscordComponentButtonStyle;
|
||||
allowedUsers?: string[];
|
||||
fields: DiscordModalFieldSpec[];
|
||||
};
|
||||
|
||||
@ -156,6 +161,7 @@ export type DiscordComponentEntry = {
|
||||
id: string;
|
||||
kind: "button" | "select" | "modal-trigger";
|
||||
label: string;
|
||||
callbackData?: string;
|
||||
selectType?: DiscordComponentSelectType;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
modalId?: string;
|
||||
@ -188,6 +194,7 @@ export type DiscordModalFieldDefinition = {
|
||||
export type DiscordModalEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
callbackData?: string;
|
||||
fields: DiscordModalFieldDefinition[];
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
@ -196,6 +203,7 @@ export type DiscordModalEntry = {
|
||||
messageId?: string;
|
||||
createdAt?: number;
|
||||
expiresAt?: number;
|
||||
allowedUsers?: string[];
|
||||
};
|
||||
|
||||
export type DiscordComponentBuildResult = {
|
||||
@ -364,6 +372,7 @@ function parseButtonSpec(raw: unknown, label: string): DiscordComponentButtonSpe
|
||||
label: readString(obj.label, `${label}.label`),
|
||||
style,
|
||||
url,
|
||||
callbackData: readOptionalString(obj.callbackData),
|
||||
emoji:
|
||||
typeof obj.emoji === "object" && obj.emoji && !Array.isArray(obj.emoji)
|
||||
? {
|
||||
@ -395,10 +404,12 @@ function parseSelectSpec(raw: unknown, label: string): DiscordComponentSelectSpe
|
||||
}
|
||||
return {
|
||||
type,
|
||||
callbackData: readOptionalString(obj.callbackData),
|
||||
placeholder: readOptionalString(obj.placeholder),
|
||||
minValues: readOptionalNumber(obj.minValues),
|
||||
maxValues: readOptionalNumber(obj.maxValues),
|
||||
options: parseSelectOptions(obj.options, `${label}.options`),
|
||||
allowedUsers: readOptionalStringArray(obj.allowedUsers, `${label}.allowedUsers`),
|
||||
};
|
||||
}
|
||||
|
||||
@ -578,8 +589,10 @@ export function readDiscordComponentSpec(raw: unknown): DiscordComponentMessageS
|
||||
);
|
||||
modal = {
|
||||
title: readString(modalObj.title, "components.modal.title"),
|
||||
callbackData: readOptionalString(modalObj.callbackData),
|
||||
triggerLabel: readOptionalString(modalObj.triggerLabel),
|
||||
triggerStyle: readOptionalString(modalObj.triggerStyle) as DiscordComponentButtonStyle,
|
||||
allowedUsers: readOptionalStringArray(modalObj.allowedUsers, "components.modal.allowedUsers"),
|
||||
fields,
|
||||
};
|
||||
}
|
||||
@ -718,6 +731,7 @@ function createButtonComponent(params: {
|
||||
id: componentId,
|
||||
kind: params.modalId ? "modal-trigger" : "button",
|
||||
label: params.spec.label,
|
||||
callbackData: params.spec.callbackData,
|
||||
modalId: params.modalId,
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
},
|
||||
@ -758,8 +772,10 @@ function createSelectComponent(params: {
|
||||
id: componentId,
|
||||
kind: "select",
|
||||
label: params.spec.placeholder ?? "select",
|
||||
callbackData: params.spec.callbackData,
|
||||
selectType: "string",
|
||||
options: options.map((option) => ({ value: option.value, label: option.label })),
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -777,7 +793,9 @@ function createSelectComponent(params: {
|
||||
id: componentId,
|
||||
kind: "select",
|
||||
label: params.spec.placeholder ?? "user select",
|
||||
callbackData: params.spec.callbackData,
|
||||
selectType: "user",
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -795,7 +813,9 @@ function createSelectComponent(params: {
|
||||
id: componentId,
|
||||
kind: "select",
|
||||
label: params.spec.placeholder ?? "role select",
|
||||
callbackData: params.spec.callbackData,
|
||||
selectType: "role",
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -813,7 +833,9 @@ function createSelectComponent(params: {
|
||||
id: componentId,
|
||||
kind: "select",
|
||||
label: params.spec.placeholder ?? "mentionable select",
|
||||
callbackData: params.spec.callbackData,
|
||||
selectType: "mentionable",
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -830,7 +852,9 @@ function createSelectComponent(params: {
|
||||
id: componentId,
|
||||
kind: "select",
|
||||
label: params.spec.placeholder ?? "channel select",
|
||||
callbackData: params.spec.callbackData,
|
||||
selectType: "channel",
|
||||
allowedUsers: params.spec.allowedUsers,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1047,16 +1071,19 @@ export function buildDiscordComponentMessage(params: {
|
||||
modals.push({
|
||||
id: modalId,
|
||||
title: params.spec.modal.title,
|
||||
callbackData: params.spec.modal.callbackData,
|
||||
fields,
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: params.agentId,
|
||||
accountId: params.accountId,
|
||||
reusable: params.spec.reusable,
|
||||
allowedUsers: params.spec.modal.allowedUsers,
|
||||
});
|
||||
|
||||
const triggerSpec: DiscordComponentButtonSpec = {
|
||||
label: params.spec.modal.triggerLabel ?? "Open form",
|
||||
style: params.spec.modal.triggerStyle ?? "primary",
|
||||
allowedUsers: params.spec.modal.allowedUsers,
|
||||
};
|
||||
|
||||
const { component, entry } = createButtonComponent({
|
||||
|
||||
@ -1,74 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fetchDiscord: vi.fn(),
|
||||
normalizeDiscordToken: vi.fn((token: string) => token.trim()),
|
||||
resolveDiscordAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveDiscordAccount: mocks.resolveDiscordAccount,
|
||||
}));
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
fetchDiscord: mocks.fetchDiscord,
|
||||
}));
|
||||
|
||||
vi.mock("./token.js", () => ({
|
||||
normalizeDiscordToken: mocks.normalizeDiscordToken,
|
||||
}));
|
||||
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive } from "./directory-live.js";
|
||||
|
||||
function makeParams(overrides: Partial<DirectoryConfigParams> = {}): DirectoryConfigParams {
|
||||
return {
|
||||
cfg: {} as DirectoryConfigParams["cfg"],
|
||||
cfg: {
|
||||
channels: {
|
||||
discord: {
|
||||
token: "test-token",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("discord directory live lookups", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.resolveDiscordAccount.mockReturnValue({ token: "test-token" });
|
||||
mocks.normalizeDiscordToken.mockImplementation((token: string) => token.trim());
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns empty group directory when token is missing", async () => {
|
||||
mocks.normalizeDiscordToken.mockReturnValue("");
|
||||
|
||||
const rows = await listDiscordDirectoryGroupsLive(makeParams({ query: "general" }));
|
||||
const rows = await listDiscordDirectoryGroupsLive({
|
||||
...makeParams(),
|
||||
cfg: { channels: { discord: { token: "" } } } as OpenClawConfig,
|
||||
query: "general",
|
||||
});
|
||||
|
||||
expect(rows).toEqual([]);
|
||||
expect(mocks.fetchDiscord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns empty peer directory without query and skips guild listing", async () => {
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
|
||||
const rows = await listDiscordDirectoryPeersLive(makeParams({ query: " " }));
|
||||
|
||||
expect(rows).toEqual([]);
|
||||
expect(mocks.fetchDiscord).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("filters group channels by query and respects limit", async () => {
|
||||
mocks.fetchDiscord.mockImplementation(async (path: string) => {
|
||||
if (path === "/users/@me/guilds") {
|
||||
return [
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/users/@me/guilds")) {
|
||||
return jsonResponse([
|
||||
{ id: "g1", name: "Guild 1" },
|
||||
{ id: "g2", name: "Guild 2" },
|
||||
];
|
||||
]);
|
||||
}
|
||||
if (path === "/guilds/g1/channels") {
|
||||
return [
|
||||
if (url.endsWith("/guilds/g1/channels")) {
|
||||
return jsonResponse([
|
||||
{ id: "c1", name: "general" },
|
||||
{ id: "c2", name: "random" },
|
||||
];
|
||||
]);
|
||||
}
|
||||
if (path === "/guilds/g2/channels") {
|
||||
return [{ id: "c3", name: "announcements" }];
|
||||
if (url.endsWith("/guilds/g2/channels")) {
|
||||
return jsonResponse([{ id: "c3", name: "announcements" }]);
|
||||
}
|
||||
return [];
|
||||
return jsonResponse([]);
|
||||
});
|
||||
|
||||
const rows = await listDiscordDirectoryGroupsLive(makeParams({ query: "an", limit: 2 }));
|
||||
@ -80,21 +78,22 @@ describe("discord directory live lookups", () => {
|
||||
});
|
||||
|
||||
it("returns ranked peer results and caps member search by limit", async () => {
|
||||
mocks.fetchDiscord.mockImplementation(async (path: string) => {
|
||||
if (path === "/users/@me/guilds") {
|
||||
return [{ id: "g1", name: "Guild 1" }];
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/users/@me/guilds")) {
|
||||
return jsonResponse([{ id: "g1", name: "Guild 1" }]);
|
||||
}
|
||||
if (path.startsWith("/guilds/g1/members/search?")) {
|
||||
const params = new URLSearchParams(path.split("?")[1] ?? "");
|
||||
if (url.includes("/guilds/g1/members/search?")) {
|
||||
const params = new URL(url).searchParams;
|
||||
expect(params.get("query")).toBe("alice");
|
||||
expect(params.get("limit")).toBe("2");
|
||||
return [
|
||||
return jsonResponse([
|
||||
{ user: { id: "u1", username: "alice", bot: false }, nick: "Ali" },
|
||||
{ user: { id: "u2", username: "alice-bot", bot: true }, nick: null },
|
||||
{ user: { id: "u3", username: "ignored", bot: false }, nick: null },
|
||||
];
|
||||
]);
|
||||
}
|
||||
return [];
|
||||
return jsonResponse([]);
|
||||
});
|
||||
|
||||
const rows = await listDiscordDirectoryPeersLive(makeParams({ query: "alice", limit: 2 }));
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
type ModalInteraction,
|
||||
type RoleSelectMenuInteraction,
|
||||
type StringSelectMenuInteraction,
|
||||
type TopLevelComponents,
|
||||
type UserSelectMenuInteraction,
|
||||
} from "@buape/carbon";
|
||||
import type { APIStringSelectComponent } from "discord-api-types/v10";
|
||||
@ -40,6 +41,12 @@ import { logDebug, logError } from "../../../../src/logger.js";
|
||||
import { getAgentScopedMediaLocalRoots } from "../../../../src/media/local-roots.js";
|
||||
import { issuePairingChallenge } from "../../../../src/pairing/pairing-challenge.js";
|
||||
import { upsertChannelPairingRequest } from "../../../../src/pairing/pairing-store.js";
|
||||
import {
|
||||
buildPluginBindingResolvedText,
|
||||
parsePluginBindingApprovalCustomId,
|
||||
resolvePluginConversationBindingApproval,
|
||||
} from "../../../../src/plugins/conversation-binding.js";
|
||||
import { dispatchPluginInteractiveHandler } from "../../../../src/plugins/interactive.js";
|
||||
import { resolveAgentRoute } from "../../../../src/routing/resolve-route.js";
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "../../../../src/runtime.js";
|
||||
import {
|
||||
@ -771,6 +778,159 @@ function formatModalSubmissionText(
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function resolveDiscordInteractionId(interaction: AgentComponentInteraction): string {
|
||||
const rawId =
|
||||
interaction.rawData && typeof interaction.rawData === "object" && "id" in interaction.rawData
|
||||
? (interaction.rawData as { id?: unknown }).id
|
||||
: undefined;
|
||||
if (typeof rawId === "string" && rawId.trim()) {
|
||||
return rawId.trim();
|
||||
}
|
||||
if (typeof rawId === "number" && Number.isFinite(rawId)) {
|
||||
return String(rawId);
|
||||
}
|
||||
return `discord-interaction:${Date.now()}`;
|
||||
}
|
||||
|
||||
async function dispatchPluginDiscordInteractiveEvent(params: {
|
||||
ctx: AgentComponentContext;
|
||||
interaction: AgentComponentInteraction;
|
||||
interactionCtx: ComponentInteractionContext;
|
||||
channelCtx: DiscordChannelContext;
|
||||
isAuthorizedSender: boolean;
|
||||
data: string;
|
||||
kind: "button" | "select" | "modal";
|
||||
values?: string[];
|
||||
fields?: Array<{ id: string; name: string; values: string[] }>;
|
||||
messageId?: string;
|
||||
}): Promise<"handled" | "unmatched"> {
|
||||
const normalizedConversationId =
|
||||
params.interactionCtx.rawGuildId || params.channelCtx.channelType === ChannelType.GroupDM
|
||||
? `channel:${params.interactionCtx.channelId}`
|
||||
: `user:${params.interactionCtx.userId}`;
|
||||
let responded = false;
|
||||
const respond = {
|
||||
acknowledge: async () => {
|
||||
responded = true;
|
||||
await params.interaction.acknowledge();
|
||||
},
|
||||
reply: async ({ text, ephemeral = true }: { text: string; ephemeral?: boolean }) => {
|
||||
responded = true;
|
||||
await params.interaction.reply({
|
||||
content: text,
|
||||
ephemeral,
|
||||
});
|
||||
},
|
||||
followUp: async ({ text, ephemeral = true }: { text: string; ephemeral?: boolean }) => {
|
||||
responded = true;
|
||||
await params.interaction.followUp({
|
||||
content: text,
|
||||
ephemeral,
|
||||
});
|
||||
},
|
||||
editMessage: async ({
|
||||
text,
|
||||
components,
|
||||
}: {
|
||||
text?: string;
|
||||
components?: TopLevelComponents[];
|
||||
}) => {
|
||||
if (!("update" in params.interaction) || typeof params.interaction.update !== "function") {
|
||||
throw new Error("Discord interaction cannot update the source message");
|
||||
}
|
||||
responded = true;
|
||||
await params.interaction.update({
|
||||
...(text !== undefined ? { content: text } : {}),
|
||||
...(components !== undefined ? { components } : {}),
|
||||
});
|
||||
},
|
||||
clearComponents: async (input?: { text?: string }) => {
|
||||
if (!("update" in params.interaction) || typeof params.interaction.update !== "function") {
|
||||
throw new Error("Discord interaction cannot clear components on the source message");
|
||||
}
|
||||
responded = true;
|
||||
await params.interaction.update({
|
||||
...(input?.text !== undefined ? { content: input.text } : {}),
|
||||
components: [],
|
||||
});
|
||||
},
|
||||
};
|
||||
const pluginBindingApproval = parsePluginBindingApprovalCustomId(params.data);
|
||||
if (pluginBindingApproval) {
|
||||
const resolved = await resolvePluginConversationBindingApproval({
|
||||
approvalId: pluginBindingApproval.approvalId,
|
||||
decision: pluginBindingApproval.decision,
|
||||
senderId: params.interactionCtx.userId,
|
||||
});
|
||||
let cleared = false;
|
||||
try {
|
||||
await respond.clearComponents();
|
||||
cleared = true;
|
||||
} catch {
|
||||
try {
|
||||
await respond.acknowledge();
|
||||
} catch {
|
||||
// Interaction may already be acknowledged; continue with best-effort follow-up.
|
||||
}
|
||||
}
|
||||
try {
|
||||
await respond.followUp({
|
||||
text: buildPluginBindingResolvedText(resolved),
|
||||
ephemeral: true,
|
||||
});
|
||||
} catch (err) {
|
||||
logError(`discord plugin binding approval: failed to follow up: ${String(err)}`);
|
||||
if (!cleared) {
|
||||
try {
|
||||
await respond.reply({
|
||||
text: buildPluginBindingResolvedText(resolved),
|
||||
ephemeral: true,
|
||||
});
|
||||
} catch {
|
||||
// Interaction may no longer accept a direct reply.
|
||||
}
|
||||
}
|
||||
}
|
||||
return "handled";
|
||||
}
|
||||
const dispatched = await dispatchPluginInteractiveHandler({
|
||||
channel: "discord",
|
||||
data: params.data,
|
||||
interactionId: resolveDiscordInteractionId(params.interaction),
|
||||
ctx: {
|
||||
accountId: params.ctx.accountId,
|
||||
interactionId: resolveDiscordInteractionId(params.interaction),
|
||||
conversationId: normalizedConversationId,
|
||||
parentConversationId: params.channelCtx.parentId,
|
||||
guildId: params.interactionCtx.rawGuildId,
|
||||
senderId: params.interactionCtx.userId,
|
||||
senderUsername: params.interactionCtx.username,
|
||||
auth: { isAuthorizedSender: params.isAuthorizedSender },
|
||||
interaction: {
|
||||
kind: params.kind,
|
||||
messageId: params.messageId,
|
||||
values: params.values,
|
||||
fields: params.fields,
|
||||
},
|
||||
},
|
||||
respond,
|
||||
});
|
||||
if (!dispatched.matched) {
|
||||
return "unmatched";
|
||||
}
|
||||
if (dispatched.handled) {
|
||||
if (!responded) {
|
||||
try {
|
||||
await respond.acknowledge();
|
||||
} catch {
|
||||
// Interaction may have expired after the handler finished.
|
||||
}
|
||||
}
|
||||
return "handled";
|
||||
}
|
||||
return "unmatched";
|
||||
}
|
||||
|
||||
function resolveComponentCommandAuthorized(params: {
|
||||
ctx: AgentComponentContext;
|
||||
interactionCtx: ComponentInteractionContext;
|
||||
@ -1102,6 +1262,17 @@ async function handleDiscordComponentEvent(params: {
|
||||
guildEntries: params.ctx.guildEntries,
|
||||
});
|
||||
const channelCtx = resolveDiscordChannelContext(params.interaction);
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(params.ctx.discordConfig);
|
||||
const channelConfig = resolveDiscordChannelConfigWithFallback({
|
||||
guildInfo,
|
||||
channelId,
|
||||
channelName: channelCtx.channelName,
|
||||
channelSlug: channelCtx.channelSlug,
|
||||
parentId: channelCtx.parentId,
|
||||
parentName: channelCtx.parentName,
|
||||
parentSlug: channelCtx.parentSlug,
|
||||
scope: channelCtx.isThread ? "thread" : "channel",
|
||||
});
|
||||
const unauthorizedReply = `You are not authorized to use this ${params.componentLabel}.`;
|
||||
const memberAllowed = await ensureGuildComponentMemberAllowed({
|
||||
interaction: params.interaction,
|
||||
@ -1114,7 +1285,7 @@ async function handleDiscordComponentEvent(params: {
|
||||
replyOpts,
|
||||
componentLabel: params.componentLabel,
|
||||
unauthorizedReply,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
|
||||
allowNameMatching,
|
||||
});
|
||||
if (!memberAllowed) {
|
||||
return;
|
||||
@ -1127,11 +1298,18 @@ async function handleDiscordComponentEvent(params: {
|
||||
replyOpts,
|
||||
componentLabel: params.componentLabel,
|
||||
unauthorizedReply,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
|
||||
allowNameMatching,
|
||||
});
|
||||
if (!componentAllowed) {
|
||||
return;
|
||||
}
|
||||
const commandAuthorized = resolveComponentCommandAuthorized({
|
||||
ctx: params.ctx,
|
||||
interactionCtx,
|
||||
channelConfig,
|
||||
guildInfo,
|
||||
allowNameMatching,
|
||||
});
|
||||
|
||||
const consumed = resolveDiscordComponentEntry({
|
||||
id: parsed.componentId,
|
||||
@ -1162,6 +1340,22 @@ async function handleDiscordComponentEvent(params: {
|
||||
}
|
||||
|
||||
const values = params.values ? mapSelectValues(consumed, params.values) : undefined;
|
||||
if (consumed.callbackData) {
|
||||
const pluginDispatch = await dispatchPluginDiscordInteractiveEvent({
|
||||
ctx: params.ctx,
|
||||
interaction: params.interaction,
|
||||
interactionCtx,
|
||||
channelCtx,
|
||||
isAuthorizedSender: commandAuthorized,
|
||||
data: consumed.callbackData,
|
||||
kind: consumed.kind === "select" ? "select" : "button",
|
||||
values,
|
||||
messageId: consumed.messageId ?? params.interaction.message?.id,
|
||||
});
|
||||
if (pluginDispatch === "handled") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const eventText = formatDiscordComponentEventText({
|
||||
kind: consumed.kind === "select" ? "select" : "button",
|
||||
label: consumed.label,
|
||||
@ -1706,6 +1900,17 @@ class DiscordComponentModal extends Modal {
|
||||
guildEntries: this.ctx.guildEntries,
|
||||
});
|
||||
const channelCtx = resolveDiscordChannelContext(interaction);
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(this.ctx.discordConfig);
|
||||
const channelConfig = resolveDiscordChannelConfigWithFallback({
|
||||
guildInfo,
|
||||
channelId,
|
||||
channelName: channelCtx.channelName,
|
||||
channelSlug: channelCtx.channelSlug,
|
||||
parentId: channelCtx.parentId,
|
||||
parentName: channelCtx.parentName,
|
||||
parentSlug: channelCtx.parentSlug,
|
||||
scope: channelCtx.isThread ? "thread" : "channel",
|
||||
});
|
||||
const memberAllowed = await ensureGuildComponentMemberAllowed({
|
||||
interaction,
|
||||
guildInfo,
|
||||
@ -1717,12 +1922,37 @@ class DiscordComponentModal extends Modal {
|
||||
replyOpts,
|
||||
componentLabel: "form",
|
||||
unauthorizedReply: "You are not authorized to use this form.",
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(this.ctx.discordConfig),
|
||||
allowNameMatching,
|
||||
});
|
||||
if (!memberAllowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modalAllowed = await ensureComponentUserAllowed({
|
||||
entry: {
|
||||
id: modalEntry.id,
|
||||
kind: "button",
|
||||
label: modalEntry.title,
|
||||
allowedUsers: modalEntry.allowedUsers,
|
||||
},
|
||||
interaction,
|
||||
user,
|
||||
replyOpts,
|
||||
componentLabel: "form",
|
||||
unauthorizedReply: "You are not authorized to use this form.",
|
||||
allowNameMatching,
|
||||
});
|
||||
if (!modalAllowed) {
|
||||
return;
|
||||
}
|
||||
const commandAuthorized = resolveComponentCommandAuthorized({
|
||||
ctx: this.ctx,
|
||||
interactionCtx,
|
||||
channelConfig,
|
||||
guildInfo,
|
||||
allowNameMatching,
|
||||
});
|
||||
|
||||
const consumed = resolveDiscordModalEntry({
|
||||
id: modalId,
|
||||
consume: !modalEntry.reusable,
|
||||
@ -1739,6 +1969,28 @@ class DiscordComponentModal extends Modal {
|
||||
return;
|
||||
}
|
||||
|
||||
if (consumed.callbackData) {
|
||||
const fields = consumed.fields.map((field) => ({
|
||||
id: field.id,
|
||||
name: field.name,
|
||||
values: resolveModalFieldValues(field, interaction),
|
||||
}));
|
||||
const pluginDispatch = await dispatchPluginDiscordInteractiveEvent({
|
||||
ctx: this.ctx,
|
||||
interaction,
|
||||
interactionCtx,
|
||||
channelCtx,
|
||||
isAuthorizedSender: commandAuthorized,
|
||||
data: consumed.callbackData,
|
||||
kind: "modal",
|
||||
fields,
|
||||
messageId: consumed.messageId,
|
||||
});
|
||||
if (pluginDispatch === "handled") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await interaction.acknowledge();
|
||||
} catch (err) {
|
||||
|
||||
@ -755,14 +755,13 @@ export class DiscordThreadUpdateListener extends ThreadUpdateListener {
|
||||
return;
|
||||
}
|
||||
const logger = this.logger ?? discordEventQueueLog;
|
||||
logger.info("Discord thread archived — resetting session", { threadId });
|
||||
const count = await closeDiscordThreadSessions({
|
||||
cfg: this.cfg,
|
||||
accountId: this.accountId,
|
||||
threadId,
|
||||
});
|
||||
if (count > 0) {
|
||||
logger.info("Discord thread sessions reset after archival", { threadId, count });
|
||||
logger.info("Discord thread archived — reset sessions", { threadId, count });
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
|
||||
@ -90,6 +90,20 @@ function createThreadClient(params: { threadId: string; parentId: string }): Dis
|
||||
} as unknown as DiscordClient;
|
||||
}
|
||||
|
||||
function createDmClient(channelId: string): DiscordClient {
|
||||
return {
|
||||
fetchChannel: async (id: string) => {
|
||||
if (id === channelId) {
|
||||
return {
|
||||
id: channelId,
|
||||
type: ChannelType.DM,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as unknown as DiscordClient;
|
||||
}
|
||||
|
||||
async function runThreadBoundPreflight(params: {
|
||||
threadId: string;
|
||||
parentId: string;
|
||||
@ -157,6 +171,25 @@ async function runGuildPreflight(params: {
|
||||
});
|
||||
}
|
||||
|
||||
async function runDmPreflight(params: {
|
||||
channelId: string;
|
||||
message: import("@buape/carbon").Message;
|
||||
discordConfig: DiscordConfig;
|
||||
}) {
|
||||
return preflightDiscordMessage({
|
||||
...createPreflightArgs({
|
||||
cfg: DEFAULT_PREFLIGHT_CFG,
|
||||
discordConfig: params.discordConfig,
|
||||
data: {
|
||||
channel_id: params.channelId,
|
||||
author: params.message.author,
|
||||
message: params.message,
|
||||
} as DiscordMessageEvent,
|
||||
client: createDmClient(params.channelId),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function runMentionOnlyBotPreflight(params: {
|
||||
channelId: string;
|
||||
guildId: string;
|
||||
@ -258,6 +291,60 @@ describe("preflightDiscordMessage", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("restores direct-message bindings by user target instead of DM channel id", async () => {
|
||||
registerSessionBindingAdapter({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
listBySession: () => [],
|
||||
resolveByConversation: (ref) =>
|
||||
ref.conversationId === "user:user-1"
|
||||
? createThreadBinding({
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "user:user-1",
|
||||
},
|
||||
metadata: {
|
||||
pluginBindingOwner: "plugin",
|
||||
pluginId: "openclaw-codex-app-server",
|
||||
pluginRoot: "/Users/huntharo/github/openclaw-app-server",
|
||||
},
|
||||
})
|
||||
: null,
|
||||
});
|
||||
|
||||
const result = await runDmPreflight({
|
||||
channelId: "dm-channel-1",
|
||||
message: createDiscordMessage({
|
||||
id: "m-dm-1",
|
||||
channelId: "dm-channel-1",
|
||||
content: "who are you",
|
||||
author: {
|
||||
id: "user-1",
|
||||
bot: false,
|
||||
username: "alice",
|
||||
},
|
||||
}),
|
||||
discordConfig: {
|
||||
allowBots: true,
|
||||
dmPolicy: "open",
|
||||
} as DiscordConfig,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.threadBinding).toMatchObject({
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "user:user-1",
|
||||
},
|
||||
metadata: {
|
||||
pluginBindingOwner: "plugin",
|
||||
pluginId: "openclaw-codex-app-server",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps bound-thread regular bot messages flowing when allowBots=true", async () => {
|
||||
const threadBinding = createThreadBinding({
|
||||
targetKind: "session",
|
||||
|
||||
@ -29,6 +29,7 @@ import { enqueueSystemEvent } from "../../../../src/infra/system-events.js";
|
||||
import { logDebug } from "../../../../src/logger.js";
|
||||
import { getChildLogger } from "../../../../src/logging.js";
|
||||
import { buildPairingReply } from "../../../../src/pairing/pairing-messages.js";
|
||||
import { isPluginOwnedSessionBindingRecord } from "../../../../src/plugins/conversation-binding.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../../src/routing/session-key.js";
|
||||
import { fetchPluralKitMessageInfo } from "../pluralkit.js";
|
||||
import { sendMessageDiscord } from "../send.js";
|
||||
@ -350,12 +351,13 @@ export async function preflightDiscordMessage(
|
||||
}),
|
||||
parentConversationId: earlyThreadParentId,
|
||||
});
|
||||
const bindingConversationId = isDirectMessage ? `user:${author.id}` : messageChannelId;
|
||||
let threadBinding: SessionBindingRecord | undefined;
|
||||
threadBinding =
|
||||
getSessionBindingService().resolveByConversation({
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
conversationId: messageChannelId,
|
||||
conversationId: bindingConversationId,
|
||||
parentConversationId: earlyThreadParentId,
|
||||
}) ?? undefined;
|
||||
const configuredRoute =
|
||||
@ -384,7 +386,9 @@ export async function preflightDiscordMessage(
|
||||
logVerbose(`discord: drop bound-thread webhook echo message ${message.id}`);
|
||||
return null;
|
||||
}
|
||||
const boundSessionKey = threadBinding?.targetSessionKey?.trim();
|
||||
const boundSessionKey = isPluginOwnedSessionBindingRecord(threadBinding)
|
||||
? ""
|
||||
: threadBinding?.targetSessionKey?.trim();
|
||||
const effectiveRoute = resolveDiscordEffectiveRoute({
|
||||
route,
|
||||
boundSessionKey,
|
||||
@ -392,7 +396,7 @@ export async function preflightDiscordMessage(
|
||||
matchedBy: "binding.channel",
|
||||
});
|
||||
const boundAgentId = boundSessionKey ? effectiveRoute.agentId : undefined;
|
||||
const isBoundThreadSession = Boolean(boundSessionKey && earlyThreadChannel);
|
||||
const isBoundThreadSession = Boolean(threadBinding && earlyThreadChannel);
|
||||
if (
|
||||
isBoundThreadBotSystemMessage({
|
||||
isBoundThreadSession,
|
||||
|
||||
@ -5,10 +5,12 @@ import type {
|
||||
StringSelectMenuInteraction,
|
||||
} from "@buape/carbon";
|
||||
import type { Client } from "@buape/carbon";
|
||||
import { ChannelType } from "discord-api-types/v10";
|
||||
import type { GatewayPresenceUpdate } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
||||
import type { DiscordAccountConfig } from "../../../../src/config/types.discord.js";
|
||||
import { buildPluginBindingApprovalCustomId } from "../../../../src/plugins/conversation-binding.js";
|
||||
import { buildAgentSessionKey } from "../../../../src/routing/resolve-route.js";
|
||||
import {
|
||||
clearDiscordComponentEntries,
|
||||
@ -52,6 +54,9 @@ const deliverDiscordReplyMock = vi.hoisted(() => vi.fn());
|
||||
const recordInboundSessionMock = vi.hoisted(() => vi.fn());
|
||||
const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn());
|
||||
const resolveStorePathMock = vi.hoisted(() => vi.fn());
|
||||
const dispatchPluginInteractiveHandlerMock = vi.hoisted(() => vi.fn());
|
||||
const resolvePluginConversationBindingApprovalMock = vi.hoisted(() => vi.fn());
|
||||
const buildPluginBindingResolvedTextMock = vi.hoisted(() => vi.fn());
|
||||
let lastDispatchCtx: Record<string, unknown> | undefined;
|
||||
|
||||
vi.mock("../../../../src/pairing/pairing-store.js", () => ({
|
||||
@ -88,6 +93,27 @@ vi.mock("../../../../src/config/sessions.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/plugins/conversation-binding.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../../../src/plugins/conversation-binding.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolvePluginConversationBindingApproval: (...args: unknown[]) =>
|
||||
resolvePluginConversationBindingApprovalMock(...args),
|
||||
buildPluginBindingResolvedText: (...args: unknown[]) =>
|
||||
buildPluginBindingResolvedTextMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/plugins/interactive.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/plugins/interactive.js")>();
|
||||
return {
|
||||
...actual,
|
||||
dispatchPluginInteractiveHandler: (...args: unknown[]) =>
|
||||
dispatchPluginInteractiveHandlerMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
describe("agent components", () => {
|
||||
const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig;
|
||||
|
||||
@ -341,6 +367,38 @@ describe("discord component interactions", () => {
|
||||
recordInboundSessionMock.mockClear().mockResolvedValue(undefined);
|
||||
readSessionUpdatedAtMock.mockClear().mockReturnValue(undefined);
|
||||
resolveStorePathMock.mockClear().mockReturnValue("/tmp/openclaw-sessions-test.json");
|
||||
dispatchPluginInteractiveHandlerMock.mockReset().mockResolvedValue({
|
||||
matched: false,
|
||||
handled: false,
|
||||
duplicate: false,
|
||||
});
|
||||
resolvePluginConversationBindingApprovalMock.mockReset().mockResolvedValue({
|
||||
status: "approved",
|
||||
binding: {
|
||||
bindingId: "binding-1",
|
||||
pluginId: "openclaw-codex-app-server",
|
||||
pluginName: "OpenClaw App Server",
|
||||
pluginRoot: "/plugins/codex",
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "user:123456789",
|
||||
boundAt: Date.now(),
|
||||
},
|
||||
request: {
|
||||
id: "approval-1",
|
||||
pluginId: "openclaw-codex-app-server",
|
||||
pluginName: "OpenClaw App Server",
|
||||
pluginRoot: "/plugins/codex",
|
||||
requestedAt: Date.now(),
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "user:123456789",
|
||||
},
|
||||
},
|
||||
decision: "allow-once",
|
||||
});
|
||||
buildPluginBindingResolvedTextMock.mockReset().mockReturnValue("Binding approved.");
|
||||
});
|
||||
|
||||
it("routes button clicks with reply references", async () => {
|
||||
@ -499,6 +557,200 @@ describe("discord component interactions", () => {
|
||||
expect(acknowledge).toHaveBeenCalledTimes(1);
|
||||
expect(resolveDiscordModalEntry({ id: "mdl_1", consume: false })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("passes false auth to plugin Discord interactions for non-allowlisted guild users", async () => {
|
||||
registerDiscordComponentEntries({
|
||||
entries: [createButtonEntry({ callbackData: "codex:approve" })],
|
||||
modals: [],
|
||||
});
|
||||
dispatchPluginInteractiveHandlerMock.mockResolvedValue({
|
||||
matched: true,
|
||||
handled: true,
|
||||
duplicate: false,
|
||||
});
|
||||
|
||||
const button = createDiscordComponentButton(
|
||||
createComponentContext({
|
||||
cfg: {
|
||||
commands: { useAccessGroups: true },
|
||||
channels: { discord: { replyToMode: "first" } },
|
||||
} as OpenClawConfig,
|
||||
allowFrom: ["owner-1"],
|
||||
}),
|
||||
);
|
||||
const { interaction } = createComponentButtonInteraction({
|
||||
rawData: {
|
||||
channel_id: "guild-channel",
|
||||
guild_id: "guild-1",
|
||||
id: "interaction-guild-plugin-1",
|
||||
member: { roles: [] },
|
||||
} as unknown as ButtonInteraction["rawData"],
|
||||
guild: { id: "guild-1", name: "Test Guild" } as unknown as ButtonInteraction["guild"],
|
||||
});
|
||||
|
||||
await button.run(interaction, { cid: "btn_1" } as ComponentData);
|
||||
|
||||
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ctx: expect.objectContaining({
|
||||
auth: { isAuthorizedSender: false },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(dispatchReplyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes true auth to plugin Discord interactions for allowlisted guild users", async () => {
|
||||
registerDiscordComponentEntries({
|
||||
entries: [createButtonEntry({ callbackData: "codex:approve" })],
|
||||
modals: [],
|
||||
});
|
||||
dispatchPluginInteractiveHandlerMock.mockResolvedValue({
|
||||
matched: true,
|
||||
handled: true,
|
||||
duplicate: false,
|
||||
});
|
||||
|
||||
const button = createDiscordComponentButton(
|
||||
createComponentContext({
|
||||
cfg: {
|
||||
commands: { useAccessGroups: true },
|
||||
channels: { discord: { replyToMode: "first" } },
|
||||
} as OpenClawConfig,
|
||||
allowFrom: ["123456789"],
|
||||
}),
|
||||
);
|
||||
const { interaction } = createComponentButtonInteraction({
|
||||
rawData: {
|
||||
channel_id: "guild-channel",
|
||||
guild_id: "guild-1",
|
||||
id: "interaction-guild-plugin-2",
|
||||
member: { roles: [] },
|
||||
} as unknown as ButtonInteraction["rawData"],
|
||||
guild: { id: "guild-1", name: "Test Guild" } as unknown as ButtonInteraction["guild"],
|
||||
});
|
||||
|
||||
await button.run(interaction, { cid: "btn_1" } as ComponentData);
|
||||
|
||||
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ctx: expect.objectContaining({
|
||||
auth: { isAuthorizedSender: true },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(dispatchReplyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes plugin Discord interactions in group DMs by channel id instead of sender id", async () => {
|
||||
registerDiscordComponentEntries({
|
||||
entries: [createButtonEntry({ callbackData: "codex:approve" })],
|
||||
modals: [],
|
||||
});
|
||||
dispatchPluginInteractiveHandlerMock.mockResolvedValue({
|
||||
matched: true,
|
||||
handled: true,
|
||||
duplicate: false,
|
||||
});
|
||||
|
||||
const button = createDiscordComponentButton(createComponentContext());
|
||||
const { interaction } = createComponentButtonInteraction({
|
||||
rawData: {
|
||||
channel_id: "group-dm-1",
|
||||
id: "interaction-group-dm-1",
|
||||
} as unknown as ButtonInteraction["rawData"],
|
||||
channel: {
|
||||
id: "group-dm-1",
|
||||
type: ChannelType.GroupDM,
|
||||
} as unknown as ButtonInteraction["channel"],
|
||||
});
|
||||
|
||||
await button.run(interaction, { cid: "btn_1" } as ComponentData);
|
||||
|
||||
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ctx: expect.objectContaining({
|
||||
conversationId: "channel:group-dm-1",
|
||||
senderId: "123456789",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(dispatchReplyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not fall through to Claw when a plugin Discord interaction already replied", async () => {
|
||||
registerDiscordComponentEntries({
|
||||
entries: [createButtonEntry({ callbackData: "codex:approve" })],
|
||||
modals: [],
|
||||
});
|
||||
dispatchPluginInteractiveHandlerMock.mockImplementation(async (params: any) => {
|
||||
await params.respond.reply({ text: "✓", ephemeral: true });
|
||||
return {
|
||||
matched: true,
|
||||
handled: true,
|
||||
duplicate: false,
|
||||
};
|
||||
});
|
||||
|
||||
const button = createDiscordComponentButton(createComponentContext());
|
||||
const { interaction, reply } = createComponentButtonInteraction();
|
||||
|
||||
await button.run(interaction, { cid: "btn_1" } as ComponentData);
|
||||
|
||||
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledTimes(1);
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓", ephemeral: true });
|
||||
expect(dispatchReplyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls through to built-in Discord component routing when a plugin declines handling", async () => {
|
||||
registerDiscordComponentEntries({
|
||||
entries: [createButtonEntry({ callbackData: "codex:approve" })],
|
||||
modals: [],
|
||||
});
|
||||
dispatchPluginInteractiveHandlerMock.mockResolvedValue({
|
||||
matched: true,
|
||||
handled: false,
|
||||
duplicate: false,
|
||||
});
|
||||
|
||||
const button = createDiscordComponentButton(createComponentContext());
|
||||
const { interaction, reply } = createComponentButtonInteraction();
|
||||
|
||||
await button.run(interaction, { cid: "btn_1" } as ComponentData);
|
||||
|
||||
expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledTimes(1);
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓" });
|
||||
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves plugin binding approvals without falling through to Claw", async () => {
|
||||
registerDiscordComponentEntries({
|
||||
entries: [
|
||||
createButtonEntry({
|
||||
callbackData: buildPluginBindingApprovalCustomId("approval-1", "allow-once"),
|
||||
}),
|
||||
],
|
||||
modals: [],
|
||||
});
|
||||
const button = createDiscordComponentButton(createComponentContext());
|
||||
const update = vi.fn().mockResolvedValue(undefined);
|
||||
const followUp = vi.fn().mockResolvedValue(undefined);
|
||||
const interaction = {
|
||||
...(createComponentButtonInteraction().interaction as any),
|
||||
update,
|
||||
followUp,
|
||||
} as ButtonInteraction;
|
||||
|
||||
await button.run(interaction, { cid: "btn_1" } as ComponentData);
|
||||
|
||||
expect(resolvePluginConversationBindingApprovalMock).toHaveBeenCalledTimes(1);
|
||||
expect(update).toHaveBeenCalledWith({ components: [] });
|
||||
expect(followUp).toHaveBeenCalledWith({
|
||||
content: "Binding approved.",
|
||||
ephemeral: true,
|
||||
});
|
||||
expect(dispatchReplyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDiscordOwnerAllowFrom", () => {
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
Row,
|
||||
StringSelectMenu,
|
||||
TextDisplay,
|
||||
type TopLevelComponents,
|
||||
type AutocompleteInteraction,
|
||||
type ButtonInteraction,
|
||||
type CommandInteraction,
|
||||
@ -274,6 +275,12 @@ function hasRenderableReplyPayload(payload: ReplyPayload): boolean {
|
||||
if (payload.mediaUrls?.some((entry) => entry.trim())) {
|
||||
return true;
|
||||
}
|
||||
const discordData = payload.channelData?.discord as
|
||||
| { components?: TopLevelComponents[] }
|
||||
| undefined;
|
||||
if (Array.isArray(discordData?.components) && discordData.components.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -1772,13 +1779,25 @@ async function deliverDiscordInteractionReply(params: {
|
||||
const { interaction, payload, textLimit, maxLinesPerMessage, preferFollowUp, chunkMode } = params;
|
||||
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
|
||||
const text = payload.text ?? "";
|
||||
const discordData = payload.channelData?.discord as
|
||||
| { components?: TopLevelComponents[] }
|
||||
| undefined;
|
||||
let firstMessageComponents =
|
||||
Array.isArray(discordData?.components) && discordData.components.length > 0
|
||||
? discordData.components
|
||||
: undefined;
|
||||
|
||||
let hasReplied = false;
|
||||
const sendMessage = async (content: string, files?: { name: string; data: Buffer }[]) => {
|
||||
const sendMessage = async (
|
||||
content: string,
|
||||
files?: { name: string; data: Buffer }[],
|
||||
components?: TopLevelComponents[],
|
||||
) => {
|
||||
const payload =
|
||||
files && files.length > 0
|
||||
? {
|
||||
content,
|
||||
...(components ? { components } : {}),
|
||||
files: files.map((file) => {
|
||||
if (file.data instanceof Blob) {
|
||||
return { name: file.name, data: file.data };
|
||||
@ -1787,15 +1806,20 @@ async function deliverDiscordInteractionReply(params: {
|
||||
return { name: file.name, data: new Blob([arrayBuffer]) };
|
||||
}),
|
||||
}
|
||||
: { content };
|
||||
: {
|
||||
content,
|
||||
...(components ? { components } : {}),
|
||||
};
|
||||
await safeDiscordInteractionCall("interaction send", async () => {
|
||||
if (!preferFollowUp && !hasReplied) {
|
||||
await interaction.reply(payload);
|
||||
hasReplied = true;
|
||||
firstMessageComponents = undefined;
|
||||
return;
|
||||
}
|
||||
await interaction.followUp(payload);
|
||||
hasReplied = true;
|
||||
firstMessageComponents = undefined;
|
||||
});
|
||||
};
|
||||
|
||||
@ -1820,7 +1844,7 @@ async function deliverDiscordInteractionReply(params: {
|
||||
chunks.push(text);
|
||||
}
|
||||
const caption = chunks[0] ?? "";
|
||||
await sendMessage(caption, media);
|
||||
await sendMessage(caption, media, firstMessageComponents);
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
if (!chunk.trim()) {
|
||||
continue;
|
||||
@ -1830,7 +1854,7 @@ async function deliverDiscordInteractionReply(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
if (!text.trim() && !firstMessageComponents) {
|
||||
return;
|
||||
}
|
||||
const chunks = chunkDiscordTextWithMode(text, {
|
||||
@ -1838,13 +1862,13 @@ async function deliverDiscordInteractionReply(params: {
|
||||
maxLines: maxLinesPerMessage,
|
||||
chunkMode,
|
||||
});
|
||||
if (!chunks.length && text) {
|
||||
if (!chunks.length && (text || firstMessageComponents)) {
|
||||
chunks.push(text);
|
||||
}
|
||||
for (const chunk of chunks) {
|
||||
if (!chunk.trim()) {
|
||||
if (!chunk.trim() && !firstMessageComponents) {
|
||||
continue;
|
||||
}
|
||||
await sendMessage(chunk);
|
||||
await sendMessage(chunk, undefined, firstMessageComponents);
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,9 +46,13 @@ const {
|
||||
resolveDiscordAllowlistConfigMock,
|
||||
resolveNativeCommandsEnabledMock,
|
||||
resolveNativeSkillsEnabledMock,
|
||||
isVerboseMock,
|
||||
shouldLogVerboseMock,
|
||||
voiceRuntimeModuleLoadedMock,
|
||||
} = vi.hoisted(() => {
|
||||
const createdBindingManagers: Array<{ stop: ReturnType<typeof vi.fn> }> = [];
|
||||
const isVerboseMock = vi.fn(() => false);
|
||||
const shouldLogVerboseMock = vi.fn(() => false);
|
||||
return {
|
||||
clientHandleDeployRequestMock: vi.fn(async () => undefined),
|
||||
clientConstructorOptionsMock: vi.fn(),
|
||||
@ -110,6 +114,8 @@ const {
|
||||
})),
|
||||
resolveNativeCommandsEnabledMock: vi.fn(() => true),
|
||||
resolveNativeSkillsEnabledMock: vi.fn(() => false),
|
||||
isVerboseMock,
|
||||
shouldLogVerboseMock,
|
||||
voiceRuntimeModuleLoadedMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
@ -210,8 +216,9 @@ vi.mock("../../../../src/config/config.js", () => ({
|
||||
|
||||
vi.mock("../../../../src/globals.js", () => ({
|
||||
danger: (v: string) => v,
|
||||
isVerbose: isVerboseMock,
|
||||
logVerbose: vi.fn(),
|
||||
shouldLogVerbose: () => false,
|
||||
shouldLogVerbose: shouldLogVerboseMock,
|
||||
warn: (v: string) => v,
|
||||
}));
|
||||
|
||||
@ -435,6 +442,8 @@ describe("monitorDiscordProvider", () => {
|
||||
});
|
||||
resolveNativeCommandsEnabledMock.mockClear().mockReturnValue(true);
|
||||
resolveNativeSkillsEnabledMock.mockClear().mockReturnValue(false);
|
||||
isVerboseMock.mockClear().mockReturnValue(false);
|
||||
shouldLogVerboseMock.mockClear().mockReturnValue(false);
|
||||
voiceRuntimeModuleLoadedMock.mockClear();
|
||||
});
|
||||
|
||||
@ -829,4 +838,50 @@ describe("monitorDiscordProvider", () => {
|
||||
expect(connectedTrue).toBeDefined();
|
||||
expect(connectedFalse).toBeDefined();
|
||||
});
|
||||
|
||||
it("logs Discord startup phases and early gateway debug events", async () => {
|
||||
const { monitorDiscordProvider } = await import("./provider.js");
|
||||
const runtime = baseRuntime();
|
||||
const emitter = new EventEmitter();
|
||||
const gateway = { emitter, isConnected: true, reconnectAttempts: 0 };
|
||||
clientGetPluginMock.mockImplementation((name: string) =>
|
||||
name === "gateway" ? gateway : undefined,
|
||||
);
|
||||
clientFetchUserMock.mockImplementationOnce(async () => {
|
||||
emitter.emit("debug", "WebSocket connection opened");
|
||||
return { id: "bot-1", username: "Molty" };
|
||||
});
|
||||
isVerboseMock.mockReturnValue(true);
|
||||
|
||||
await monitorDiscordProvider({
|
||||
config: baseConfig(),
|
||||
runtime,
|
||||
});
|
||||
|
||||
const messages = vi.mocked(runtime.log).mock.calls.map((call) => String(call[0]));
|
||||
expect(messages.some((msg) => msg.includes("fetch-application-id:start"))).toBe(true);
|
||||
expect(messages.some((msg) => msg.includes("fetch-application-id:done"))).toBe(true);
|
||||
expect(messages.some((msg) => msg.includes("deploy-commands:start"))).toBe(true);
|
||||
expect(messages.some((msg) => msg.includes("deploy-commands:done"))).toBe(true);
|
||||
expect(messages.some((msg) => msg.includes("fetch-bot-identity:start"))).toBe(true);
|
||||
expect(messages.some((msg) => msg.includes("fetch-bot-identity:done"))).toBe(true);
|
||||
expect(
|
||||
messages.some(
|
||||
(msg) => msg.includes("gateway-debug") && msg.includes("WebSocket connection opened"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps Discord startup chatter quiet by default", async () => {
|
||||
const { monitorDiscordProvider } = await import("./provider.js");
|
||||
const runtime = baseRuntime();
|
||||
|
||||
await monitorDiscordProvider({
|
||||
config: baseConfig(),
|
||||
runtime,
|
||||
});
|
||||
|
||||
const messages = vi.mocked(runtime.log).mock.calls.map((call) => String(call[0]));
|
||||
expect(messages.some((msg) => msg.includes("discord startup ["))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -38,7 +38,7 @@ import {
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "../../../../src/config/runtime-group-policy.js";
|
||||
import { createConnectedChannelStatusPatch } from "../../../../src/gateway/channel-status-patches.js";
|
||||
import { danger, logVerbose, shouldLogVerbose, warn } from "../../../../src/globals.js";
|
||||
import { danger, isVerbose, logVerbose, shouldLogVerbose, warn } from "../../../../src/globals.js";
|
||||
import { formatErrorMessage } from "../../../../src/infra/errors.js";
|
||||
import { createSubsystemLogger } from "../../../../src/logging/subsystem.js";
|
||||
import { getPluginCommandSpecs } from "../../../../src/plugins/commands.js";
|
||||
@ -273,14 +273,18 @@ async function deployDiscordCommands(params: {
|
||||
body === undefined
|
||||
? undefined
|
||||
: Buffer.byteLength(typeof body === "string" ? body : JSON.stringify(body), "utf8");
|
||||
params.runtime.log?.(
|
||||
`discord startup [${accountId}] deploy-rest:put:start ${Math.max(0, Date.now() - startupStartedAt)}ms path=${path}${typeof commandCount === "number" ? ` commands=${commandCount}` : ""}${typeof bodyBytes === "number" ? ` bytes=${bodyBytes}` : ""}`,
|
||||
);
|
||||
if (shouldLogVerbose()) {
|
||||
params.runtime.log?.(
|
||||
`discord startup [${accountId}] deploy-rest:put:start ${Math.max(0, Date.now() - startupStartedAt)}ms path=${path}${typeof commandCount === "number" ? ` commands=${commandCount}` : ""}${typeof bodyBytes === "number" ? ` bytes=${bodyBytes}` : ""}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const result = await originalPut(path, data, query);
|
||||
params.runtime.log?.(
|
||||
`discord startup [${accountId}] deploy-rest:put:done ${Math.max(0, Date.now() - startupStartedAt)}ms path=${path} requestMs=${Date.now() - startedAt}`,
|
||||
);
|
||||
if (shouldLogVerbose()) {
|
||||
params.runtime.log?.(
|
||||
`discord startup [${accountId}] deploy-rest:put:done ${Math.max(0, Date.now() - startupStartedAt)}ms path=${path} requestMs=${Date.now() - startedAt}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
params.runtime.error?.(
|
||||
@ -359,6 +363,9 @@ function logDiscordStartupPhase(params: {
|
||||
gateway?: GatewayPlugin;
|
||||
details?: string;
|
||||
}) {
|
||||
if (!isVerbose()) {
|
||||
return;
|
||||
}
|
||||
const elapsedMs = Math.max(0, Date.now() - params.startAt);
|
||||
const suffix = [params.details, formatDiscordStartupGatewayState(params.gateway)]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
@ -367,7 +374,6 @@ function logDiscordStartupPhase(params: {
|
||||
`discord startup [${params.accountId}] ${params.phase} ${elapsedMs}ms${suffix ? ` ${suffix}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
function formatDiscordDeployErrorDetails(err: unknown): string {
|
||||
if (!err || typeof err !== "object") {
|
||||
return "";
|
||||
@ -769,6 +775,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
const lifecycleGateway = client.getPlugin<GatewayPlugin>("gateway");
|
||||
earlyGatewayEmitter = getDiscordGatewayEmitter(lifecycleGateway);
|
||||
onEarlyGatewayDebug = (msg: unknown) => {
|
||||
if (!isVerbose()) {
|
||||
return;
|
||||
}
|
||||
runtime.log?.(
|
||||
`discord startup [${account.accountId}] gateway-debug ${Math.max(0, Date.now() - startupStartedAt)}ms ${String(msg)}`,
|
||||
);
|
||||
|
||||
@ -17,7 +17,7 @@ import {
|
||||
} from "./thread-bindings.types.js";
|
||||
|
||||
function buildThreadTarget(threadId: string): string {
|
||||
return `channel:${threadId}`;
|
||||
return /^(channel:|user:)/i.test(threadId) ? threadId : `channel:${threadId}`;
|
||||
}
|
||||
|
||||
export function isThreadArchived(raw: unknown): boolean {
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
setRuntimeConfigSnapshot,
|
||||
type OpenClawConfig,
|
||||
} from "../../../../src/config/config.js";
|
||||
import { getSessionBindingService } from "../../../../src/infra/outbound/session-binding-service.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({}));
|
||||
@ -788,6 +789,57 @@ describe("thread binding lifecycle", () => {
|
||||
expect(usedTokenNew).toBe(true);
|
||||
});
|
||||
|
||||
it("binds current Discord DMs as direct conversation bindings", async () => {
|
||||
createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
idleTimeoutMs: 24 * 60 * 60 * 1000,
|
||||
maxAgeMs: 0,
|
||||
});
|
||||
|
||||
hoisted.restGet.mockClear();
|
||||
hoisted.restPost.mockClear();
|
||||
|
||||
const bound = await getSessionBindingService().bind({
|
||||
targetSessionKey: "plugin-binding:openclaw-codex-app-server:dm",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "user:1177378744822943744",
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
pluginBindingOwner: "plugin",
|
||||
pluginId: "openclaw-codex-app-server",
|
||||
pluginRoot: "/Users/huntharo/github/openclaw-app-server",
|
||||
},
|
||||
});
|
||||
|
||||
expect(bound).toMatchObject({
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "user:1177378744822943744",
|
||||
parentConversationId: "user:1177378744822943744",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
getSessionBindingService().resolveByConversation({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "user:1177378744822943744",
|
||||
}),
|
||||
).toMatchObject({
|
||||
conversation: {
|
||||
conversationId: "user:1177378744822943744",
|
||||
},
|
||||
});
|
||||
expect(hoisted.restGet).not.toHaveBeenCalled();
|
||||
expect(hoisted.restPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps overlapping thread ids isolated per account", async () => {
|
||||
const a = createThreadBindingManager({
|
||||
accountId: "a",
|
||||
@ -948,6 +1000,47 @@ describe("thread binding lifecycle", () => {
|
||||
expect(manager.getByThreadId("thread-acp-uncertain")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not reconcile plugin-owned direct bindings as stale ACP sessions", async () => {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
idleTimeoutMs: 24 * 60 * 60 * 1000,
|
||||
maxAgeMs: 0,
|
||||
});
|
||||
|
||||
await manager.bindTarget({
|
||||
threadId: "user:1177378744822943744",
|
||||
channelId: "user:1177378744822943744",
|
||||
targetKind: "acp",
|
||||
targetSessionKey: "plugin-binding:openclaw-codex-app-server:dm",
|
||||
agentId: "codex",
|
||||
metadata: {
|
||||
pluginBindingOwner: "plugin",
|
||||
pluginId: "openclaw-codex-app-server",
|
||||
pluginRoot: "/Users/huntharo/github/openclaw-app-server",
|
||||
},
|
||||
});
|
||||
|
||||
hoisted.readAcpSessionEntry.mockReturnValue(null);
|
||||
|
||||
const result = await reconcileAcpThreadBindingsOnStartup({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(result.checked).toBe(0);
|
||||
expect(result.removed).toBe(0);
|
||||
expect(result.staleSessionKeys).toEqual([]);
|
||||
expect(manager.getByThreadId("user:1177378744822943744")).toMatchObject({
|
||||
threadId: "user:1177378744822943744",
|
||||
metadata: {
|
||||
pluginBindingOwner: "plugin",
|
||||
pluginId: "openclaw-codex-app-server",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("removes ACP bindings when health probe marks running session as stale", async () => {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
|
||||
@ -323,7 +323,12 @@ export async function reconcileAcpThreadBindingsOnStartup(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const acpBindings = manager.listBindings().filter((binding) => binding.targetKind === "acp");
|
||||
const acpBindings = manager
|
||||
.listBindings()
|
||||
.filter(
|
||||
(binding) =>
|
||||
binding.targetKind === "acp" && binding.metadata?.pluginBindingOwner !== "plugin",
|
||||
);
|
||||
const staleBindings: ThreadBindingRecord[] = [];
|
||||
const probeTargets: Array<{
|
||||
binding: ThreadBindingRecord;
|
||||
|
||||
@ -117,6 +117,11 @@ function toThreadBindingTargetKind(raw: BindingTargetKind): "subagent" | "acp" {
|
||||
return raw === "subagent" ? "subagent" : "acp";
|
||||
}
|
||||
|
||||
function isDirectConversationBindingId(value?: string | null): boolean {
|
||||
const trimmed = value?.trim();
|
||||
return Boolean(trimmed && /^(user:|channel:)/i.test(trimmed));
|
||||
}
|
||||
|
||||
function toSessionBindingRecord(
|
||||
record: ThreadBindingRecord,
|
||||
defaults: { idleTimeoutMs: number; maxAgeMs: number },
|
||||
@ -158,6 +163,7 @@ function toSessionBindingRecord(
|
||||
record,
|
||||
defaultMaxAgeMs: defaults.maxAgeMs,
|
||||
}),
|
||||
...record.metadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -264,6 +270,8 @@ export function createThreadBindingManager(
|
||||
const cfg = resolveCurrentCfg();
|
||||
let threadId = normalizeThreadId(bindParams.threadId);
|
||||
let channelId = bindParams.channelId?.trim() || "";
|
||||
const directConversationBinding =
|
||||
isDirectConversationBindingId(threadId) || isDirectConversationBindingId(channelId);
|
||||
|
||||
if (!threadId && bindParams.createThread) {
|
||||
if (!channelId) {
|
||||
@ -287,6 +295,10 @@ export function createThreadBindingManager(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!channelId && directConversationBinding) {
|
||||
channelId = threadId;
|
||||
}
|
||||
|
||||
if (!channelId) {
|
||||
channelId =
|
||||
(await resolveChannelIdForBinding({
|
||||
@ -309,12 +321,12 @@ export function createThreadBindingManager(
|
||||
const targetKind = normalizeTargetKind(bindParams.targetKind, targetSessionKey);
|
||||
let webhookId = bindParams.webhookId?.trim() || "";
|
||||
let webhookToken = bindParams.webhookToken?.trim() || "";
|
||||
if (!webhookId || !webhookToken) {
|
||||
if (!directConversationBinding && (!webhookId || !webhookToken)) {
|
||||
const cachedWebhook = findReusableWebhook({ accountId, channelId });
|
||||
webhookId = cachedWebhook.webhookId ?? "";
|
||||
webhookToken = cachedWebhook.webhookToken ?? "";
|
||||
}
|
||||
if (!webhookId || !webhookToken) {
|
||||
if (!directConversationBinding && (!webhookId || !webhookToken)) {
|
||||
const createdWebhook = await createWebhookForChannel({
|
||||
cfg,
|
||||
accountId,
|
||||
@ -341,6 +353,10 @@ export function createThreadBindingManager(
|
||||
lastActivityAt: now,
|
||||
idleTimeoutMs,
|
||||
maxAgeMs,
|
||||
metadata:
|
||||
bindParams.metadata && typeof bindParams.metadata === "object"
|
||||
? { ...bindParams.metadata }
|
||||
: undefined,
|
||||
};
|
||||
|
||||
setBindingRecord(record);
|
||||
@ -508,6 +524,9 @@ export function createThreadBindingManager(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (isDirectConversationBindingId(binding.threadId)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const channel = await rest.get(Routes.channel(binding.threadId));
|
||||
if (!channel || typeof channel !== "object") {
|
||||
@ -604,6 +623,7 @@ export function createThreadBindingManager(
|
||||
label,
|
||||
boundBy,
|
||||
introText,
|
||||
metadata,
|
||||
});
|
||||
return bound
|
||||
? toSessionBindingRecord(bound, {
|
||||
|
||||
@ -183,6 +183,8 @@ function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBin
|
||||
typeof value.maxAgeMs === "number" && Number.isFinite(value.maxAgeMs)
|
||||
? Math.max(0, Math.floor(value.maxAgeMs))
|
||||
: undefined;
|
||||
const metadata =
|
||||
value.metadata && typeof value.metadata === "object" ? { ...value.metadata } : undefined;
|
||||
const legacyExpiresAt =
|
||||
typeof (value as { expiresAt?: unknown }).expiresAt === "number" &&
|
||||
Number.isFinite((value as { expiresAt?: unknown }).expiresAt)
|
||||
@ -222,6 +224,7 @@ function normalizePersistedBinding(threadIdKey: string, raw: unknown): ThreadBin
|
||||
lastActivityAt,
|
||||
idleTimeoutMs: migratedIdleTimeoutMs,
|
||||
maxAgeMs: migratedMaxAgeMs,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ export type ThreadBindingRecord = {
|
||||
idleTimeoutMs?: number;
|
||||
/** Hard max-age window in milliseconds from bind time (0 disables hard cap). */
|
||||
maxAgeMs?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type PersistedThreadBindingRecord = ThreadBindingRecord & {
|
||||
@ -56,6 +57,7 @@ export type ThreadBindingManager = {
|
||||
introText?: string;
|
||||
webhookId?: string;
|
||||
webhookToken?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) => Promise<ThreadBindingRecord | null>;
|
||||
unbindThread: (params: {
|
||||
threadId: string;
|
||||
|
||||
@ -135,6 +135,24 @@ describe("closeDiscordThreadSessions", () => {
|
||||
expect(hoisted.updateSessionStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not recount sessions that were already reset", async () => {
|
||||
const store = {
|
||||
[MATCHED_KEY]: { updatedAt: 0 },
|
||||
[UNMATCHED_KEY]: { updatedAt: 1_700_000_000_001 },
|
||||
};
|
||||
setupStore(store);
|
||||
|
||||
const count = await closeDiscordThreadSessions({
|
||||
cfg: {},
|
||||
accountId: "default",
|
||||
threadId: THREAD_ID,
|
||||
});
|
||||
|
||||
expect(count).toBe(0);
|
||||
expect(store[MATCHED_KEY].updatedAt).toBe(0);
|
||||
expect(store[UNMATCHED_KEY].updatedAt).toBe(1_700_000_000_001);
|
||||
});
|
||||
|
||||
it("resolves the store path using cfg.session.store and accountId", async () => {
|
||||
const store = {};
|
||||
setupStore(store);
|
||||
|
||||
@ -47,6 +47,9 @@ export async function closeDiscordThreadSessions(params: {
|
||||
if (!entry || !sessionKeyContainsThreadId(key)) {
|
||||
continue;
|
||||
}
|
||||
if (entry.updatedAt === 0) {
|
||||
continue;
|
||||
}
|
||||
// Setting updatedAt to 0 signals that this session is stale.
|
||||
// evaluateSessionFreshness will create a new session on the next message.
|
||||
entry.updatedAt = 0;
|
||||
|
||||
@ -1,319 +0,0 @@
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingDmPolicy,
|
||||
} from "../../../src/channels/plugins/onboarding-types.js";
|
||||
import { configureChannelAccessWithAllowlist } from "../../../src/channels/plugins/onboarding/channel-access-configure.js";
|
||||
import {
|
||||
applySingleTokenPromptResult,
|
||||
parseMentionOrPrefixedId,
|
||||
noteChannelLookupFailure,
|
||||
noteChannelLookupSummary,
|
||||
patchChannelConfigForAccount,
|
||||
promptLegacyChannelAllowFrom,
|
||||
resolveAccountIdForConfigure,
|
||||
resolveOnboardingAccountId,
|
||||
runSingleChannelSecretStep,
|
||||
setAccountGroupPolicyForChannel,
|
||||
setLegacyChannelDmPolicyWithAllowFrom,
|
||||
setOnboardingChannelEnabled,
|
||||
} from "../../../src/channels/plugins/onboarding/helpers.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { DiscordGuildEntry } from "../../../src/config/types.discord.js";
|
||||
import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
|
||||
import { formatDocsLink } from "../../../src/terminal/links.js";
|
||||
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
|
||||
import { inspectDiscordAccount } from "./account-inspect.js";
|
||||
import {
|
||||
listDiscordAccountIds,
|
||||
resolveDefaultDiscordAccountId,
|
||||
resolveDiscordAccount,
|
||||
} from "./accounts.js";
|
||||
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
|
||||
import {
|
||||
resolveDiscordChannelAllowlist,
|
||||
type DiscordChannelResolution,
|
||||
} from "./resolve-channels.js";
|
||||
import { resolveDiscordUserAllowlist } from "./resolve-users.js";
|
||||
|
||||
const channel = "discord" as const;
|
||||
|
||||
async function noteDiscordTokenHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"1) Discord Developer Portal → Applications → New Application",
|
||||
"2) Bot → Add Bot → Reset Token → copy token",
|
||||
"3) OAuth2 → URL Generator → scope 'bot' → invite to your server",
|
||||
"Tip: enable Message Content Intent if you need message text. (Bot → Privileged Gateway Intents → Message Content Intent)",
|
||||
`Docs: ${formatDocsLink("/discord", "discord")}`,
|
||||
].join("\n"),
|
||||
"Discord bot token",
|
||||
);
|
||||
}
|
||||
|
||||
function setDiscordGuildChannelAllowlist(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
entries: Array<{
|
||||
guildKey: string;
|
||||
channelKey?: string;
|
||||
}>,
|
||||
): OpenClawConfig {
|
||||
const baseGuilds =
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? (cfg.channels?.discord?.guilds ?? {})
|
||||
: (cfg.channels?.discord?.accounts?.[accountId]?.guilds ?? {});
|
||||
const guilds: Record<string, DiscordGuildEntry> = { ...baseGuilds };
|
||||
for (const entry of entries) {
|
||||
const guildKey = entry.guildKey || "*";
|
||||
const existing = guilds[guildKey] ?? {};
|
||||
if (entry.channelKey) {
|
||||
const channels = { ...existing.channels };
|
||||
channels[entry.channelKey] = { allow: true };
|
||||
guilds[guildKey] = { ...existing, channels };
|
||||
} else {
|
||||
guilds[guildKey] = existing;
|
||||
}
|
||||
}
|
||||
return patchChannelConfigForAccount({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId,
|
||||
patch: { guilds },
|
||||
});
|
||||
}
|
||||
|
||||
async function promptDiscordAllowFrom(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const accountId = resolveOnboardingAccountId({
|
||||
accountId: params.accountId,
|
||||
defaultAccountId: resolveDefaultDiscordAccountId(params.cfg),
|
||||
});
|
||||
const resolved = resolveDiscordAccount({ cfg: params.cfg, accountId });
|
||||
const token = resolved.token;
|
||||
const existing =
|
||||
params.cfg.channels?.discord?.allowFrom ?? params.cfg.channels?.discord?.dm?.allowFrom ?? [];
|
||||
const parseId = (value: string) =>
|
||||
parseMentionOrPrefixedId({
|
||||
value,
|
||||
mentionPattern: /^<@!?(\d+)>$/,
|
||||
prefixPattern: /^(user:|discord:)/i,
|
||||
idPattern: /^\d+$/,
|
||||
});
|
||||
|
||||
return promptLegacyChannelAllowFrom({
|
||||
cfg: params.cfg,
|
||||
channel: "discord",
|
||||
prompter: params.prompter,
|
||||
existing,
|
||||
token,
|
||||
noteTitle: "Discord allowlist",
|
||||
noteLines: [
|
||||
"Allowlist Discord DMs by username (we resolve to user ids).",
|
||||
"Examples:",
|
||||
"- 123456789012345678",
|
||||
"- @alice",
|
||||
"- alice#1234",
|
||||
"Multiple entries: comma-separated.",
|
||||
`Docs: ${formatDocsLink("/discord", "discord")}`,
|
||||
],
|
||||
message: "Discord allowFrom (usernames or ids)",
|
||||
placeholder: "@alice, 123456789012345678",
|
||||
parseId,
|
||||
invalidWithoutTokenNote: "Bot token missing; use numeric user ids (or mention form) only.",
|
||||
resolveEntries: ({ token, entries }) =>
|
||||
resolveDiscordUserAllowlist({
|
||||
token,
|
||||
entries,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Discord",
|
||||
channel,
|
||||
policyKey: "channels.discord.dmPolicy",
|
||||
allowFromKey: "channels.discord.allowFrom",
|
||||
getCurrent: (cfg) =>
|
||||
cfg.channels?.discord?.dmPolicy ?? cfg.channels?.discord?.dm?.policy ?? "pairing",
|
||||
setPolicy: (cfg, policy) =>
|
||||
setLegacyChannelDmPolicyWithAllowFrom({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
dmPolicy: policy,
|
||||
}),
|
||||
promptAllowFrom: promptDiscordAllowFrom,
|
||||
};
|
||||
|
||||
export const discordOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const configured = listDiscordAccountIds(cfg).some((accountId) => {
|
||||
const account = inspectDiscordAccount({ cfg, accountId });
|
||||
return account.configured;
|
||||
});
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`Discord: ${configured ? "configured" : "needs token"}`],
|
||||
selectionHint: configured ? "configured" : "needs token",
|
||||
quickstartScore: configured ? 2 : 1,
|
||||
};
|
||||
},
|
||||
configure: async ({ cfg, prompter, options, accountOverrides, shouldPromptAccountIds }) => {
|
||||
const defaultDiscordAccountId = resolveDefaultDiscordAccountId(cfg);
|
||||
const discordAccountId = await resolveAccountIdForConfigure({
|
||||
cfg,
|
||||
prompter,
|
||||
label: "Discord",
|
||||
accountOverride: accountOverrides.discord,
|
||||
shouldPromptAccountIds,
|
||||
listAccountIds: listDiscordAccountIds,
|
||||
defaultAccountId: defaultDiscordAccountId,
|
||||
});
|
||||
|
||||
let next = cfg;
|
||||
const resolvedAccount = resolveDiscordAccount({
|
||||
cfg: next,
|
||||
accountId: discordAccountId,
|
||||
});
|
||||
const allowEnv = discordAccountId === DEFAULT_ACCOUNT_ID;
|
||||
const tokenStep = await runSingleChannelSecretStep({
|
||||
cfg: next,
|
||||
prompter,
|
||||
providerHint: "discord",
|
||||
credentialLabel: "Discord bot token",
|
||||
secretInputMode: options?.secretInputMode,
|
||||
accountConfigured: Boolean(resolvedAccount.token),
|
||||
hasConfigToken: hasConfiguredSecretInput(resolvedAccount.config.token),
|
||||
allowEnv,
|
||||
envValue: process.env.DISCORD_BOT_TOKEN,
|
||||
envPrompt: "DISCORD_BOT_TOKEN detected. Use env var?",
|
||||
keepPrompt: "Discord token already configured. Keep it?",
|
||||
inputPrompt: "Enter Discord bot token",
|
||||
preferredEnvVar: allowEnv ? "DISCORD_BOT_TOKEN" : undefined,
|
||||
onMissingConfigured: async () => await noteDiscordTokenHelp(prompter),
|
||||
applyUseEnv: async (cfg) =>
|
||||
applySingleTokenPromptResult({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: discordAccountId,
|
||||
tokenPatchKey: "token",
|
||||
tokenResult: { useEnv: true, token: null },
|
||||
}),
|
||||
applySet: async (cfg, value) =>
|
||||
applySingleTokenPromptResult({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: discordAccountId,
|
||||
tokenPatchKey: "token",
|
||||
tokenResult: { useEnv: false, token: value },
|
||||
}),
|
||||
});
|
||||
next = tokenStep.cfg;
|
||||
|
||||
const currentEntries = Object.entries(resolvedAccount.config.guilds ?? {}).flatMap(
|
||||
([guildKey, value]) => {
|
||||
const channels = value?.channels ?? {};
|
||||
const channelKeys = Object.keys(channels);
|
||||
if (channelKeys.length === 0) {
|
||||
const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey;
|
||||
return [input];
|
||||
}
|
||||
return channelKeys.map((channelKey) => `${guildKey}/${channelKey}`);
|
||||
},
|
||||
);
|
||||
next = await configureChannelAccessWithAllowlist({
|
||||
cfg: next,
|
||||
prompter,
|
||||
label: "Discord channels",
|
||||
currentPolicy: resolvedAccount.config.groupPolicy ?? "allowlist",
|
||||
currentEntries,
|
||||
placeholder: "My Server/#general, guildId/channelId, #support",
|
||||
updatePrompt: Boolean(resolvedAccount.config.guilds),
|
||||
setPolicy: (cfg, policy) =>
|
||||
setAccountGroupPolicyForChannel({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: discordAccountId,
|
||||
groupPolicy: policy,
|
||||
}),
|
||||
resolveAllowlist: async ({ cfg, entries }) => {
|
||||
const accountWithTokens = resolveDiscordAccount({
|
||||
cfg,
|
||||
accountId: discordAccountId,
|
||||
});
|
||||
let resolved: DiscordChannelResolution[] = entries.map((input) => ({
|
||||
input,
|
||||
resolved: false,
|
||||
}));
|
||||
const activeToken = accountWithTokens.token || tokenStep.resolvedValue || "";
|
||||
if (activeToken && entries.length > 0) {
|
||||
try {
|
||||
resolved = await resolveDiscordChannelAllowlist({
|
||||
token: activeToken,
|
||||
entries,
|
||||
});
|
||||
const resolvedChannels = resolved.filter((entry) => entry.resolved && entry.channelId);
|
||||
const resolvedGuilds = resolved.filter(
|
||||
(entry) => entry.resolved && entry.guildId && !entry.channelId,
|
||||
);
|
||||
const unresolved = resolved
|
||||
.filter((entry) => !entry.resolved)
|
||||
.map((entry) => entry.input);
|
||||
await noteChannelLookupSummary({
|
||||
prompter,
|
||||
label: "Discord channels",
|
||||
resolvedSections: [
|
||||
{
|
||||
title: "Resolved channels",
|
||||
values: resolvedChannels
|
||||
.map((entry) => entry.channelId)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
},
|
||||
{
|
||||
title: "Resolved guilds",
|
||||
values: resolvedGuilds
|
||||
.map((entry) => entry.guildId)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
},
|
||||
],
|
||||
unresolved,
|
||||
});
|
||||
} catch (err) {
|
||||
await noteChannelLookupFailure({
|
||||
prompter,
|
||||
label: "Discord channels",
|
||||
error: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
},
|
||||
applyAllowlist: ({ cfg, resolved }) => {
|
||||
const allowlistEntries: Array<{ guildKey: string; channelKey?: string }> = [];
|
||||
for (const entry of resolved) {
|
||||
const guildKey =
|
||||
entry.guildId ??
|
||||
(entry.guildName ? normalizeDiscordSlug(entry.guildName) : undefined) ??
|
||||
"*";
|
||||
const channelKey =
|
||||
entry.channelId ??
|
||||
(entry.channelName ? normalizeDiscordSlug(entry.channelName) : undefined);
|
||||
if (!channelKey && guildKey === "*") {
|
||||
continue;
|
||||
}
|
||||
allowlistEntries.push({ guildKey, ...(channelKey ? { channelKey } : {}) });
|
||||
}
|
||||
return setDiscordGuildChannelAllowlist(cfg, discordAccountId, allowlistEntries);
|
||||
},
|
||||
});
|
||||
|
||||
return { cfg: next, accountId: discordAccountId };
|
||||
},
|
||||
dmPolicy,
|
||||
disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false),
|
||||
};
|
||||
@ -45,6 +45,7 @@ export {
|
||||
sendVoiceMessageDiscord,
|
||||
} from "./send.outbound.js";
|
||||
export { sendDiscordComponentMessage } from "./send.components.js";
|
||||
export { sendTypingDiscord } from "./send.typing.js";
|
||||
export {
|
||||
fetchChannelPermissionsDiscord,
|
||||
hasAllGuildPermissionsDiscord,
|
||||
|
||||
9
extensions/discord/src/send.typing.ts
Normal file
9
extensions/discord/src/send.typing.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Routes } from "discord-api-types/v10";
|
||||
import { resolveDiscordRest } from "./client.js";
|
||||
import type { DiscordReactOpts } from "./send.types.js";
|
||||
|
||||
export async function sendTypingDiscord(channelId: string, opts: DiscordReactOpts = {}) {
|
||||
const rest = resolveDiscordRest(opts);
|
||||
await rest.post(Routes.channelTyping(channelId));
|
||||
return { ok: true, channelId };
|
||||
}
|
||||
348
extensions/discord/src/setup-core.ts
Normal file
348
extensions/discord/src/setup-core.ts
Normal file
@ -0,0 +1,348 @@
|
||||
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
|
||||
import {
|
||||
noteChannelLookupFailure,
|
||||
noteChannelLookupSummary,
|
||||
parseMentionOrPrefixedId,
|
||||
patchChannelConfigForAccount,
|
||||
setLegacyChannelDmPolicyWithAllowFrom,
|
||||
setOnboardingChannelEnabled,
|
||||
} from "../../../src/channels/plugins/onboarding/helpers.js";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
} from "../../../src/channels/plugins/setup-helpers.js";
|
||||
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
|
||||
import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { DiscordGuildEntry } from "../../../src/config/types.discord.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js";
|
||||
import { formatDocsLink } from "../../../src/terminal/links.js";
|
||||
import { inspectDiscordAccount } from "./account-inspect.js";
|
||||
import { listDiscordAccountIds, resolveDiscordAccount } from "./accounts.js";
|
||||
|
||||
const channel = "discord" as const;
|
||||
|
||||
export const DISCORD_TOKEN_HELP_LINES = [
|
||||
"1) Discord Developer Portal -> Applications -> New Application",
|
||||
"2) Bot -> Add Bot -> Reset Token -> copy token",
|
||||
"3) OAuth2 -> URL Generator -> scope 'bot' -> invite to your server",
|
||||
"Tip: enable Message Content Intent if you need message text. (Bot -> Privileged Gateway Intents -> Message Content Intent)",
|
||||
`Docs: ${formatDocsLink("/discord", "discord")}`,
|
||||
];
|
||||
|
||||
export function setDiscordGuildChannelAllowlist(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
entries: Array<{
|
||||
guildKey: string;
|
||||
channelKey?: string;
|
||||
}>,
|
||||
): OpenClawConfig {
|
||||
const baseGuilds =
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? (cfg.channels?.discord?.guilds ?? {})
|
||||
: (cfg.channels?.discord?.accounts?.[accountId]?.guilds ?? {});
|
||||
const guilds: Record<string, DiscordGuildEntry> = { ...baseGuilds };
|
||||
for (const entry of entries) {
|
||||
const guildKey = entry.guildKey || "*";
|
||||
const existing = guilds[guildKey] ?? {};
|
||||
if (entry.channelKey) {
|
||||
const channels = { ...existing.channels };
|
||||
channels[entry.channelKey] = { allow: true };
|
||||
guilds[guildKey] = { ...existing, channels };
|
||||
} else {
|
||||
guilds[guildKey] = existing;
|
||||
}
|
||||
}
|
||||
return patchChannelConfigForAccount({
|
||||
cfg,
|
||||
channel,
|
||||
accountId,
|
||||
patch: { guilds },
|
||||
});
|
||||
}
|
||||
|
||||
export function parseDiscordAllowFromId(value: string): string | null {
|
||||
return parseMentionOrPrefixedId({
|
||||
value,
|
||||
mentionPattern: /^<@!?(\d+)>$/,
|
||||
prefixPattern: /^(user:|discord:)/i,
|
||||
idPattern: /^\d+$/,
|
||||
});
|
||||
}
|
||||
|
||||
export const discordSetupAdapter: ChannelSetupAdapter = {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: ({ accountId, input }) => {
|
||||
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
|
||||
return "DISCORD_BOT_TOKEN can only be used for the default account.";
|
||||
}
|
||||
if (!input.useEnv && !input.token) {
|
||||
return "Discord requires token (or --use-env).";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
name: input.name,
|
||||
});
|
||||
const next =
|
||||
accountId !== DEFAULT_ACCOUNT_ID
|
||||
? migrateBaseNameToDefaultAccount({
|
||||
cfg: namedConfig,
|
||||
channelKey: channel,
|
||||
})
|
||||
: namedConfig;
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
discord: {
|
||||
...next.channels?.discord,
|
||||
enabled: true,
|
||||
...(input.useEnv ? {} : input.token ? { token: input.token } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
channels: {
|
||||
...next.channels,
|
||||
discord: {
|
||||
...next.channels?.discord,
|
||||
enabled: true,
|
||||
accounts: {
|
||||
...next.channels?.discord?.accounts,
|
||||
[accountId]: {
|
||||
...next.channels?.discord?.accounts?.[accountId],
|
||||
enabled: true,
|
||||
...(input.token ? { token: input.token } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export function createDiscordSetupWizardProxy(
|
||||
loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>,
|
||||
) {
|
||||
const discordDmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Discord",
|
||||
channel,
|
||||
policyKey: "channels.discord.dmPolicy",
|
||||
allowFromKey: "channels.discord.allowFrom",
|
||||
getCurrent: (cfg: OpenClawConfig) =>
|
||||
cfg.channels?.discord?.dmPolicy ?? cfg.channels?.discord?.dm?.policy ?? "pairing",
|
||||
setPolicy: (cfg: OpenClawConfig, policy) =>
|
||||
setLegacyChannelDmPolicyWithAllowFrom({
|
||||
cfg,
|
||||
channel,
|
||||
dmPolicy: policy,
|
||||
}),
|
||||
promptAllowFrom: async ({ cfg, prompter, accountId }) => {
|
||||
const wizard = (await loadWizard()).discordSetupWizard;
|
||||
if (!wizard.dmPolicy?.promptAllowFrom) {
|
||||
return cfg;
|
||||
}
|
||||
return await wizard.dmPolicy.promptAllowFrom({ cfg, prompter, accountId });
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
channel,
|
||||
status: {
|
||||
configuredLabel: "configured",
|
||||
unconfiguredLabel: "needs token",
|
||||
configuredHint: "configured",
|
||||
unconfiguredHint: "needs token",
|
||||
configuredScore: 2,
|
||||
unconfiguredScore: 1,
|
||||
resolveConfigured: ({ cfg }) =>
|
||||
listDiscordAccountIds(cfg).some((accountId) => {
|
||||
const account = inspectDiscordAccount({ cfg, accountId });
|
||||
return account.configured;
|
||||
}),
|
||||
},
|
||||
credentials: [
|
||||
{
|
||||
inputKey: "token",
|
||||
providerHint: channel,
|
||||
credentialLabel: "Discord bot token",
|
||||
preferredEnvVar: "DISCORD_BOT_TOKEN",
|
||||
helpTitle: "Discord bot token",
|
||||
helpLines: DISCORD_TOKEN_HELP_LINES,
|
||||
envPrompt: "DISCORD_BOT_TOKEN detected. Use env var?",
|
||||
keepPrompt: "Discord token already configured. Keep it?",
|
||||
inputPrompt: "Enter Discord bot token",
|
||||
allowEnv: ({ accountId }: { accountId: string }) => accountId === DEFAULT_ACCOUNT_ID,
|
||||
inspect: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) => {
|
||||
const account = inspectDiscordAccount({ cfg, accountId });
|
||||
return {
|
||||
accountConfigured: account.configured,
|
||||
hasConfiguredValue: account.tokenStatus !== "missing",
|
||||
resolvedValue: account.token?.trim() || undefined,
|
||||
envValue:
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? process.env.DISCORD_BOT_TOKEN?.trim() || undefined
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
groupAccess: {
|
||||
label: "Discord channels",
|
||||
placeholder: "My Server/#general, guildId/channelId, #support",
|
||||
currentPolicy: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) =>
|
||||
resolveDiscordAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist",
|
||||
currentEntries: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) =>
|
||||
Object.entries(resolveDiscordAccount({ cfg, accountId }).config.guilds ?? {}).flatMap(
|
||||
([guildKey, value]) => {
|
||||
const channels = value?.channels ?? {};
|
||||
const channelKeys = Object.keys(channels);
|
||||
if (channelKeys.length === 0) {
|
||||
const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey;
|
||||
return [input];
|
||||
}
|
||||
return channelKeys.map((channelKey) => `${guildKey}/${channelKey}`);
|
||||
},
|
||||
),
|
||||
updatePrompt: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) =>
|
||||
Boolean(resolveDiscordAccount({ cfg, accountId }).config.guilds),
|
||||
setPolicy: ({
|
||||
cfg,
|
||||
accountId,
|
||||
policy,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
policy: "open" | "allowlist" | "disabled";
|
||||
}) =>
|
||||
patchChannelConfigForAccount({
|
||||
cfg,
|
||||
channel,
|
||||
accountId,
|
||||
patch: { groupPolicy: policy },
|
||||
}),
|
||||
resolveAllowlist: async ({
|
||||
cfg,
|
||||
accountId,
|
||||
credentialValues,
|
||||
entries,
|
||||
prompter,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
credentialValues: { token?: string };
|
||||
entries: string[];
|
||||
prompter: { note: (message: string, title?: string) => Promise<void> };
|
||||
}) => {
|
||||
const wizard = (await loadWizard()).discordSetupWizard;
|
||||
if (!wizard.groupAccess) {
|
||||
return entries.map((input) => ({ input, resolved: false }));
|
||||
}
|
||||
try {
|
||||
return await wizard.groupAccess.resolveAllowlist({
|
||||
cfg,
|
||||
accountId,
|
||||
credentialValues,
|
||||
entries,
|
||||
prompter,
|
||||
});
|
||||
} catch (error) {
|
||||
await noteChannelLookupFailure({
|
||||
prompter,
|
||||
label: "Discord channels",
|
||||
error,
|
||||
});
|
||||
await noteChannelLookupSummary({
|
||||
prompter,
|
||||
label: "Discord channels",
|
||||
resolvedSections: [],
|
||||
unresolved: entries,
|
||||
});
|
||||
return entries.map((input) => ({ input, resolved: false }));
|
||||
}
|
||||
},
|
||||
applyAllowlist: ({
|
||||
cfg,
|
||||
accountId,
|
||||
resolved,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
resolved: unknown;
|
||||
}) => setDiscordGuildChannelAllowlist(cfg, accountId, resolved as never),
|
||||
},
|
||||
allowFrom: {
|
||||
credentialInputKey: "token",
|
||||
helpTitle: "Discord allowlist",
|
||||
helpLines: [
|
||||
"Allowlist Discord DMs by username (we resolve to user ids).",
|
||||
"Examples:",
|
||||
"- 123456789012345678",
|
||||
"- @alice",
|
||||
"- alice#1234",
|
||||
"Multiple entries: comma-separated.",
|
||||
`Docs: ${formatDocsLink("/discord", "discord")}`,
|
||||
],
|
||||
message: "Discord allowFrom (usernames or ids)",
|
||||
placeholder: "@alice, 123456789012345678",
|
||||
invalidWithoutCredentialNote:
|
||||
"Bot token missing; use numeric user ids (or mention form) only.",
|
||||
parseId: parseDiscordAllowFromId,
|
||||
resolveEntries: async ({
|
||||
cfg,
|
||||
accountId,
|
||||
credentialValues,
|
||||
entries,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
credentialValues: { token?: string };
|
||||
entries: string[];
|
||||
}) => {
|
||||
const wizard = (await loadWizard()).discordSetupWizard;
|
||||
if (!wizard.allowFrom) {
|
||||
return entries.map((input) => ({ input, resolved: false, id: null }));
|
||||
}
|
||||
return await wizard.allowFrom.resolveEntries({
|
||||
cfg,
|
||||
accountId,
|
||||
credentialValues,
|
||||
entries,
|
||||
});
|
||||
},
|
||||
apply: async ({
|
||||
cfg,
|
||||
accountId,
|
||||
allowFrom,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
allowFrom: string[];
|
||||
}) =>
|
||||
patchChannelConfigForAccount({
|
||||
cfg,
|
||||
channel,
|
||||
accountId,
|
||||
patch: { dmPolicy: "allowlist", allowFrom },
|
||||
}),
|
||||
},
|
||||
dmPolicy: discordDmPolicy,
|
||||
disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false),
|
||||
} satisfies ChannelSetupWizard;
|
||||
}
|
||||
277
extensions/discord/src/setup-surface.ts
Normal file
277
extensions/discord/src/setup-surface.ts
Normal file
@ -0,0 +1,277 @@
|
||||
import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js";
|
||||
import {
|
||||
noteChannelLookupFailure,
|
||||
noteChannelLookupSummary,
|
||||
parseMentionOrPrefixedId,
|
||||
patchChannelConfigForAccount,
|
||||
promptLegacyChannelAllowFrom,
|
||||
resolveOnboardingAccountId,
|
||||
setLegacyChannelDmPolicyWithAllowFrom,
|
||||
setOnboardingChannelEnabled,
|
||||
} from "../../../src/channels/plugins/onboarding/helpers.js";
|
||||
import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js";
|
||||
import { formatDocsLink } from "../../../src/terminal/links.js";
|
||||
import type { WizardPrompter } from "../../../src/wizard/prompts.js";
|
||||
import { inspectDiscordAccount } from "./account-inspect.js";
|
||||
import {
|
||||
listDiscordAccountIds,
|
||||
resolveDefaultDiscordAccountId,
|
||||
resolveDiscordAccount,
|
||||
} from "./accounts.js";
|
||||
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
|
||||
import {
|
||||
resolveDiscordChannelAllowlist,
|
||||
type DiscordChannelResolution,
|
||||
} from "./resolve-channels.js";
|
||||
import { resolveDiscordUserAllowlist } from "./resolve-users.js";
|
||||
import {
|
||||
discordSetupAdapter,
|
||||
DISCORD_TOKEN_HELP_LINES,
|
||||
parseDiscordAllowFromId,
|
||||
setDiscordGuildChannelAllowlist,
|
||||
} from "./setup-core.js";
|
||||
|
||||
const channel = "discord" as const;
|
||||
|
||||
async function resolveDiscordAllowFromEntries(params: { token?: string; entries: string[] }) {
|
||||
if (!params.token?.trim()) {
|
||||
return params.entries.map((input) => ({
|
||||
input,
|
||||
resolved: false,
|
||||
id: null,
|
||||
}));
|
||||
}
|
||||
const resolved = await resolveDiscordUserAllowlist({
|
||||
token: params.token,
|
||||
entries: params.entries,
|
||||
});
|
||||
return resolved.map((entry) => ({
|
||||
input: entry.input,
|
||||
resolved: entry.resolved,
|
||||
id: entry.id ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async function promptDiscordAllowFrom(params: {
|
||||
cfg: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const accountId = resolveOnboardingAccountId({
|
||||
accountId: params.accountId,
|
||||
defaultAccountId: resolveDefaultDiscordAccountId(params.cfg),
|
||||
});
|
||||
const resolved = resolveDiscordAccount({ cfg: params.cfg, accountId });
|
||||
return promptLegacyChannelAllowFrom({
|
||||
cfg: params.cfg,
|
||||
channel,
|
||||
prompter: params.prompter,
|
||||
existing: resolved.config.allowFrom ?? resolved.config.dm?.allowFrom ?? [],
|
||||
token: resolved.token,
|
||||
noteTitle: "Discord allowlist",
|
||||
noteLines: [
|
||||
"Allowlist Discord DMs by username (we resolve to user ids).",
|
||||
"Examples:",
|
||||
"- 123456789012345678",
|
||||
"- @alice",
|
||||
"- alice#1234",
|
||||
"Multiple entries: comma-separated.",
|
||||
`Docs: ${formatDocsLink("/discord", "discord")}`,
|
||||
],
|
||||
message: "Discord allowFrom (usernames or ids)",
|
||||
placeholder: "@alice, 123456789012345678",
|
||||
parseId: parseDiscordAllowFromId,
|
||||
invalidWithoutTokenNote: "Bot token missing; use numeric user ids (or mention form) only.",
|
||||
resolveEntries: ({ token, entries }) =>
|
||||
resolveDiscordUserAllowlist({
|
||||
token,
|
||||
entries,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const discordDmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "Discord",
|
||||
channel,
|
||||
policyKey: "channels.discord.dmPolicy",
|
||||
allowFromKey: "channels.discord.allowFrom",
|
||||
getCurrent: (cfg) =>
|
||||
cfg.channels?.discord?.dmPolicy ?? cfg.channels?.discord?.dm?.policy ?? "pairing",
|
||||
setPolicy: (cfg, policy) =>
|
||||
setLegacyChannelDmPolicyWithAllowFrom({
|
||||
cfg,
|
||||
channel,
|
||||
dmPolicy: policy,
|
||||
}),
|
||||
promptAllowFrom: promptDiscordAllowFrom,
|
||||
};
|
||||
|
||||
export const discordSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
status: {
|
||||
configuredLabel: "configured",
|
||||
unconfiguredLabel: "needs token",
|
||||
configuredHint: "configured",
|
||||
unconfiguredHint: "needs token",
|
||||
configuredScore: 2,
|
||||
unconfiguredScore: 1,
|
||||
resolveConfigured: ({ cfg }) =>
|
||||
listDiscordAccountIds(cfg).some(
|
||||
(accountId) => inspectDiscordAccount({ cfg, accountId }).configured,
|
||||
),
|
||||
},
|
||||
credentials: [
|
||||
{
|
||||
inputKey: "token",
|
||||
providerHint: channel,
|
||||
credentialLabel: "Discord bot token",
|
||||
preferredEnvVar: "DISCORD_BOT_TOKEN",
|
||||
helpTitle: "Discord bot token",
|
||||
helpLines: DISCORD_TOKEN_HELP_LINES,
|
||||
envPrompt: "DISCORD_BOT_TOKEN detected. Use env var?",
|
||||
keepPrompt: "Discord token already configured. Keep it?",
|
||||
inputPrompt: "Enter Discord bot token",
|
||||
allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
|
||||
inspect: ({ cfg, accountId }) => {
|
||||
const account = inspectDiscordAccount({ cfg, accountId });
|
||||
return {
|
||||
accountConfigured: account.configured,
|
||||
hasConfiguredValue: account.tokenStatus !== "missing",
|
||||
resolvedValue: account.token?.trim() || undefined,
|
||||
envValue:
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? process.env.DISCORD_BOT_TOKEN?.trim() || undefined
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
groupAccess: {
|
||||
label: "Discord channels",
|
||||
placeholder: "My Server/#general, guildId/channelId, #support",
|
||||
currentPolicy: ({ cfg, accountId }) =>
|
||||
resolveDiscordAccount({ cfg, accountId }).config.groupPolicy ?? "allowlist",
|
||||
currentEntries: ({ cfg, accountId }) =>
|
||||
Object.entries(resolveDiscordAccount({ cfg, accountId }).config.guilds ?? {}).flatMap(
|
||||
([guildKey, value]) => {
|
||||
const channels = value?.channels ?? {};
|
||||
const channelKeys = Object.keys(channels);
|
||||
if (channelKeys.length === 0) {
|
||||
const input = /^\d+$/.test(guildKey) ? `guild:${guildKey}` : guildKey;
|
||||
return [input];
|
||||
}
|
||||
return channelKeys.map((channelKey) => `${guildKey}/${channelKey}`);
|
||||
},
|
||||
),
|
||||
updatePrompt: ({ cfg, accountId }) =>
|
||||
Boolean(resolveDiscordAccount({ cfg, accountId }).config.guilds),
|
||||
setPolicy: ({ cfg, accountId, policy }) =>
|
||||
patchChannelConfigForAccount({
|
||||
cfg,
|
||||
channel,
|
||||
accountId,
|
||||
patch: { groupPolicy: policy },
|
||||
}),
|
||||
resolveAllowlist: async ({ cfg, accountId, credentialValues, entries, prompter }) => {
|
||||
const token =
|
||||
resolveDiscordAccount({ cfg, accountId }).token ||
|
||||
(typeof credentialValues.token === "string" ? credentialValues.token : "");
|
||||
let resolved: DiscordChannelResolution[] = entries.map((input) => ({
|
||||
input,
|
||||
resolved: false,
|
||||
}));
|
||||
if (!token || entries.length === 0) {
|
||||
return resolved;
|
||||
}
|
||||
try {
|
||||
resolved = await resolveDiscordChannelAllowlist({
|
||||
token,
|
||||
entries,
|
||||
});
|
||||
const resolvedChannels = resolved.filter((entry) => entry.resolved && entry.channelId);
|
||||
const resolvedGuilds = resolved.filter(
|
||||
(entry) => entry.resolved && entry.guildId && !entry.channelId,
|
||||
);
|
||||
const unresolved = resolved.filter((entry) => !entry.resolved).map((entry) => entry.input);
|
||||
await noteChannelLookupSummary({
|
||||
prompter,
|
||||
label: "Discord channels",
|
||||
resolvedSections: [
|
||||
{
|
||||
title: "Resolved channels",
|
||||
values: resolvedChannels
|
||||
.map((entry) => entry.channelId)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
},
|
||||
{
|
||||
title: "Resolved guilds",
|
||||
values: resolvedGuilds
|
||||
.map((entry) => entry.guildId)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
},
|
||||
],
|
||||
unresolved,
|
||||
});
|
||||
} catch (error) {
|
||||
await noteChannelLookupFailure({
|
||||
prompter,
|
||||
label: "Discord channels",
|
||||
error,
|
||||
});
|
||||
}
|
||||
return resolved;
|
||||
},
|
||||
applyAllowlist: ({ cfg, accountId, resolved }) => {
|
||||
const allowlistEntries: Array<{ guildKey: string; channelKey?: string }> = [];
|
||||
for (const entry of resolved as DiscordChannelResolution[]) {
|
||||
const guildKey =
|
||||
entry.guildId ??
|
||||
(entry.guildName ? normalizeDiscordSlug(entry.guildName) : undefined) ??
|
||||
"*";
|
||||
const channelKey =
|
||||
entry.channelId ??
|
||||
(entry.channelName ? normalizeDiscordSlug(entry.channelName) : undefined);
|
||||
if (!channelKey && guildKey === "*") {
|
||||
continue;
|
||||
}
|
||||
allowlistEntries.push({ guildKey, ...(channelKey ? { channelKey } : {}) });
|
||||
}
|
||||
return setDiscordGuildChannelAllowlist(cfg, accountId, allowlistEntries);
|
||||
},
|
||||
},
|
||||
allowFrom: {
|
||||
credentialInputKey: "token",
|
||||
helpTitle: "Discord allowlist",
|
||||
helpLines: [
|
||||
"Allowlist Discord DMs by username (we resolve to user ids).",
|
||||
"Examples:",
|
||||
"- 123456789012345678",
|
||||
"- @alice",
|
||||
"- alice#1234",
|
||||
"Multiple entries: comma-separated.",
|
||||
`Docs: ${formatDocsLink("/discord", "discord")}`,
|
||||
],
|
||||
message: "Discord allowFrom (usernames or ids)",
|
||||
placeholder: "@alice, 123456789012345678",
|
||||
invalidWithoutCredentialNote: "Bot token missing; use numeric user ids (or mention form) only.",
|
||||
parseId: parseDiscordAllowFromId,
|
||||
resolveEntries: async ({ cfg, accountId, credentialValues, entries }) =>
|
||||
await resolveDiscordAllowFromEntries({
|
||||
token:
|
||||
resolveDiscordAccount({ cfg, accountId }).token ||
|
||||
(typeof credentialValues.token === "string" ? credentialValues.token : ""),
|
||||
entries,
|
||||
}),
|
||||
apply: async ({ cfg, accountId, allowFrom }) =>
|
||||
patchChannelConfigForAccount({
|
||||
cfg,
|
||||
channel,
|
||||
accountId,
|
||||
patch: { dmPolicy: "allowlist", allowFrom },
|
||||
}),
|
||||
},
|
||||
dmPolicy: discordDmPolicy,
|
||||
disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false),
|
||||
};
|
||||
@ -1,13 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import { listDiscordDirectoryPeersLive } from "./directory-live.js";
|
||||
import * as directoryLive from "./directory-live.js";
|
||||
import { normalizeDiscordMessagingTarget } from "./normalize.js";
|
||||
import { parseDiscordTarget, resolveDiscordChannelId, resolveDiscordTarget } from "./targets.js";
|
||||
|
||||
vi.mock("./directory-live.js", () => ({
|
||||
listDiscordDirectoryPeersLive: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("parseDiscordTarget", () => {
|
||||
it("parses user mention and prefixes", () => {
|
||||
const cases = [
|
||||
@ -73,14 +69,15 @@ describe("resolveDiscordChannelId", () => {
|
||||
|
||||
describe("resolveDiscordTarget", () => {
|
||||
const cfg = { channels: { discord: {} } } as OpenClawConfig;
|
||||
const listPeers = vi.mocked(listDiscordDirectoryPeersLive);
|
||||
|
||||
beforeEach(() => {
|
||||
listPeers.mockClear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns a resolved user for usernames", async () => {
|
||||
listPeers.mockResolvedValueOnce([{ kind: "user", id: "user:999", name: "Jane" } as const]);
|
||||
vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive").mockResolvedValueOnce([
|
||||
{ kind: "user", id: "user:999", name: "Jane" } as const,
|
||||
]);
|
||||
|
||||
await expect(
|
||||
resolveDiscordTarget("jane", { cfg, accountId: "default" }),
|
||||
@ -88,14 +85,14 @@ describe("resolveDiscordTarget", () => {
|
||||
});
|
||||
|
||||
it("falls back to parsing when lookup misses", async () => {
|
||||
listPeers.mockResolvedValueOnce([]);
|
||||
vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive").mockResolvedValueOnce([]);
|
||||
await expect(
|
||||
resolveDiscordTarget("general", { cfg, accountId: "default" }),
|
||||
).resolves.toMatchObject({ kind: "channel", id: "general" });
|
||||
});
|
||||
|
||||
it("does not call directory lookup for explicit user ids", async () => {
|
||||
listPeers.mockResolvedValueOnce([]);
|
||||
const listPeers = vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive");
|
||||
await expect(
|
||||
resolveDiscordTarget("user:123", { cfg, accountId: "default" }),
|
||||
).resolves.toMatchObject({ kind: "user", id: "123" });
|
||||
|
||||
@ -54,6 +54,9 @@ const plugin = {
|
||||
register(api: OpenClawPluginApi) {
|
||||
setFeishuRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: feishuPlugin });
|
||||
if (api.registrationMode !== "full") {
|
||||
return;
|
||||
}
|
||||
registerFeishuSubagentHooks(api);
|
||||
registerFeishuDocTools(api);
|
||||
registerFeishuChatTools(api);
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "feishu",
|
||||
"label": "Feishu",
|
||||
|
||||
5
extensions/feishu/setup-entry.ts
Normal file
5
extensions/feishu/setup-entry.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { feishuPlugin } from "./src/channel.js";
|
||||
|
||||
export default {
|
||||
plugin: feishuPlugin,
|
||||
};
|
||||
5
extensions/feishu/src/channel.runtime.ts
Normal file
5
extensions/feishu/src/channel.runtime.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export { listFeishuDirectoryGroupsLive, listFeishuDirectoryPeersLive } from "./directory.js";
|
||||
export { feishuOutbound } from "./outbound.js";
|
||||
export { probeFeishu } from "./probe.js";
|
||||
export { addReactionFeishu, listReactionsFeishu, removeReactionFeishu } from "./reactions.js";
|
||||
export { sendCardFeishu, sendMessageFeishu } from "./send.js";
|
||||
@ -22,18 +22,10 @@ import {
|
||||
resolveDefaultFeishuAccountId,
|
||||
} from "./accounts.js";
|
||||
import { FeishuConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
listFeishuDirectoryPeers,
|
||||
listFeishuDirectoryGroups,
|
||||
listFeishuDirectoryPeersLive,
|
||||
listFeishuDirectoryGroupsLive,
|
||||
} from "./directory.js";
|
||||
import { feishuOnboardingAdapter } from "./onboarding.js";
|
||||
import { feishuOutbound } from "./outbound.js";
|
||||
import { listFeishuDirectoryPeers, listFeishuDirectoryGroups } from "./directory.static.js";
|
||||
import { resolveFeishuGroupToolPolicy } from "./policy.js";
|
||||
import { probeFeishu } from "./probe.js";
|
||||
import { addReactionFeishu, listReactionsFeishu, removeReactionFeishu } from "./reactions.js";
|
||||
import { sendCardFeishu, sendMessageFeishu } from "./send.js";
|
||||
import { getFeishuRuntime } from "./runtime.js";
|
||||
import { feishuSetupAdapter, feishuSetupWizard } from "./setup-surface.js";
|
||||
import { normalizeFeishuTarget, looksLikeFeishuId, formatFeishuTarget } from "./targets.js";
|
||||
import type { ResolvedFeishuAccount, FeishuConfig } from "./types.js";
|
||||
|
||||
@ -48,6 +40,10 @@ const meta: ChannelMeta = {
|
||||
order: 70,
|
||||
};
|
||||
|
||||
async function loadFeishuChannelRuntime() {
|
||||
return await import("./channel.runtime.js");
|
||||
}
|
||||
|
||||
function setFeishuNamedAccountEnabled(
|
||||
cfg: ClawdbotConfig,
|
||||
accountId: string,
|
||||
@ -107,6 +103,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
idLabel: "feishuUserId",
|
||||
normalizeAllowEntry: (entry) => entry.replace(/^(feishu|user|open_id):/i, ""),
|
||||
notifyApproval: async ({ cfg, id }) => {
|
||||
const { sendMessageFeishu } = await loadFeishuChannelRuntime();
|
||||
await sendMessageFeishu({
|
||||
cfg,
|
||||
to: id,
|
||||
@ -254,6 +251,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
typeof ctx.params.replyTo === "string"
|
||||
? ctx.params.replyTo.trim() || undefined
|
||||
: undefined;
|
||||
const { sendCardFeishu } = await loadFeishuChannelRuntime();
|
||||
const result = await sendCardFeishu({
|
||||
cfg: ctx.cfg,
|
||||
to,
|
||||
@ -287,6 +285,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
if (!emoji) {
|
||||
throw new Error("Emoji is required to remove a Feishu reaction.");
|
||||
}
|
||||
const { listReactionsFeishu, removeReactionFeishu } = await loadFeishuChannelRuntime();
|
||||
const matches = await listReactionsFeishu({
|
||||
cfg: ctx.cfg,
|
||||
messageId,
|
||||
@ -321,6 +320,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
"Emoji is required to add a Feishu reaction. Set clearAll=true to remove all bot reactions.",
|
||||
);
|
||||
}
|
||||
const { listReactionsFeishu, removeReactionFeishu } = await loadFeishuChannelRuntime();
|
||||
const reactions = await listReactionsFeishu({
|
||||
cfg: ctx.cfg,
|
||||
messageId,
|
||||
@ -341,6 +341,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
details: { ok: true, removed },
|
||||
};
|
||||
}
|
||||
const { addReactionFeishu } = await loadFeishuChannelRuntime();
|
||||
await addReactionFeishu({
|
||||
cfg: ctx.cfg,
|
||||
messageId,
|
||||
@ -361,6 +362,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
if (!messageId) {
|
||||
throw new Error("Feishu reactions lookup requires messageId.");
|
||||
}
|
||||
const { listReactionsFeishu } = await loadFeishuChannelRuntime();
|
||||
const reactions = await listReactionsFeishu({
|
||||
cfg: ctx.cfg,
|
||||
messageId,
|
||||
@ -390,28 +392,8 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
});
|
||||
},
|
||||
},
|
||||
setup: {
|
||||
resolveAccountId: () => DEFAULT_ACCOUNT_ID,
|
||||
applyAccountConfig: ({ cfg, accountId }) => {
|
||||
const isDefault = !accountId || accountId === DEFAULT_ACCOUNT_ID;
|
||||
|
||||
if (isDefault) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
feishu: {
|
||||
...cfg.channels?.feishu,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return setFeishuNamedAccountEnabled(cfg, accountId, true);
|
||||
},
|
||||
},
|
||||
onboarding: feishuOnboardingAdapter,
|
||||
setup: feishuSetupAdapter,
|
||||
setupWizard: feishuSetupWizard,
|
||||
messaging: {
|
||||
normalizeTarget: (raw) => normalizeFeishuTarget(raw) ?? undefined,
|
||||
targetResolver: {
|
||||
@ -436,28 +418,37 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
listPeersLive: async ({ cfg, query, limit, accountId }) =>
|
||||
listFeishuDirectoryPeersLive({
|
||||
(await loadFeishuChannelRuntime()).listFeishuDirectoryPeersLive({
|
||||
cfg,
|
||||
query: query ?? undefined,
|
||||
limit: limit ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
listGroupsLive: async ({ cfg, query, limit, accountId }) =>
|
||||
listFeishuDirectoryGroupsLive({
|
||||
(await loadFeishuChannelRuntime()).listFeishuDirectoryGroupsLive({
|
||||
cfg,
|
||||
query: query ?? undefined,
|
||||
limit: limit ?? undefined,
|
||||
accountId: accountId ?? undefined,
|
||||
}),
|
||||
},
|
||||
outbound: feishuOutbound,
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getFeishuRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
sendText: async (params) => (await loadFeishuChannelRuntime()).feishuOutbound.sendText!(params),
|
||||
sendMedia: async (params) =>
|
||||
(await loadFeishuChannelRuntime()).feishuOutbound.sendMedia!(params),
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, { port: null }),
|
||||
buildChannelSummary: ({ snapshot }) =>
|
||||
buildProbeChannelStatusSummary(snapshot, {
|
||||
port: snapshot.port ?? null,
|
||||
}),
|
||||
probeAccount: async ({ account }) => await probeFeishu(account),
|
||||
probeAccount: async ({ account }) =>
|
||||
await (await loadFeishuChannelRuntime()).probeFeishu(account),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
enabled: account.enabled,
|
||||
|
||||
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