mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-15 10:38:52 +08:00
Compare commits
131 Commits
codex/matr
...
ui/touch-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9b7ccbd6e | ||
|
|
f7483b35c3 | ||
|
|
026be9dfae | ||
|
|
18a8477f03 | ||
|
|
b41aa861d5 | ||
|
|
1dfeb21cb5 | ||
|
|
3bf540170a | ||
|
|
e5fd061470 | ||
|
|
3a4ed87599 | ||
|
|
97ae0158e8 | ||
|
|
1662f3f5da | ||
|
|
b00f3f9b64 | ||
|
|
5484225b2d | ||
|
|
a03f43d5bd | ||
|
|
84cf8c32aa | ||
|
|
65dff0c2a3 | ||
|
|
5d439a43f4 | ||
|
|
10c16d0de8 | ||
|
|
d3ca5fb8a1 | ||
|
|
cd60db8f54 | ||
|
|
9b4468fb82 | ||
|
|
49c5f4eb1e | ||
|
|
3186cbbbe2 | ||
|
|
348016d8a8 | ||
|
|
0d438921ea | ||
|
|
50a2be72fe | ||
|
|
371732f399 | ||
|
|
473ba3149b | ||
|
|
5116dbeb60 | ||
|
|
0bd4c7cd43 | ||
|
|
feddfb4859 | ||
|
|
95e20e8a35 | ||
|
|
52662d39d6 | ||
|
|
3d0b3526e0 | ||
|
|
0a30d5a4e1 | ||
|
|
f842de046c | ||
|
|
e5eda19db2 | ||
|
|
1e1bc24f80 | ||
|
|
2d87bc703f | ||
|
|
2b67a3f76e | ||
|
|
4285eb3539 | ||
|
|
0636c6eafa | ||
|
|
2a02337be2 | ||
|
|
c03b0877d0 | ||
|
|
de0285d8ea | ||
|
|
b8dd6548aa | ||
|
|
33bcf11c3f | ||
|
|
6816c76738 | ||
|
|
0f7cd59824 | ||
|
|
d6c13d9dc0 | ||
|
|
028f3c4d15 | ||
|
|
d1d36da700 | ||
|
|
fa896704d2 | ||
|
|
6ba15aadcc | ||
|
|
4079de21ce | ||
|
|
826c592deb | ||
|
|
92a40d324a | ||
|
|
3de973ffff | ||
|
|
df72ca1ece | ||
|
|
1a9114a169 | ||
|
|
1c81b82f48 | ||
|
|
24dc91c6ef | ||
|
|
e691345774 | ||
|
|
326c660775 | ||
|
|
a2518a16ac | ||
|
|
fb5ab95e03 | ||
|
|
a89cb3e10e | ||
|
|
4c9028439c | ||
|
|
2c35faf437 | ||
|
|
d2ef865073 | ||
|
|
1aae93b1fa | ||
|
|
f253f14b0b | ||
|
|
a8f433d611 | ||
|
|
bf8702973f | ||
|
|
4e706da898 | ||
|
|
1f5f3fc2ef | ||
|
|
c29458d407 | ||
|
|
a4b98f95c2 | ||
|
|
9c12b41c52 | ||
|
|
005b25e9d4 | ||
|
|
6556a40330 | ||
|
|
5c4903d3fd | ||
|
|
7ba8dd112f | ||
|
|
a34944c918 | ||
|
|
f8f9e06b58 | ||
|
|
0bfaa36126 | ||
|
|
9350cb19dd | ||
|
|
9e556f75f5 | ||
|
|
889011c08c | ||
|
|
abaa9107c5 | ||
|
|
2f21eeb3cb | ||
|
|
1777b99ccc | ||
|
|
56066dccb0 | ||
|
|
0a90b07f8d | ||
|
|
28b888cbcd | ||
|
|
cd5c2f4cb2 | ||
|
|
3cc83cb81e | ||
|
|
a41840f717 | ||
|
|
53dcafbec3 | ||
|
|
206d1be082 | ||
|
|
27d4fdf3bb | ||
|
|
6b9b32a160 | ||
|
|
682f4d1ca3 | ||
|
|
870f260772 | ||
|
|
25e6cd38b6 | ||
|
|
fa34cb887d | ||
|
|
5b2c5ee2bc | ||
|
|
055632460d | ||
|
|
889bb8a78a | ||
|
|
1313767825 | ||
|
|
b942dacf48 | ||
|
|
44521d6b20 | ||
|
|
6f060d7e6c | ||
|
|
841b1a59d7 | ||
|
|
01ae160108 | ||
|
|
d8b95d2315 | ||
|
|
fa73f5aeb5 | ||
|
|
9e8b9aba1f | ||
|
|
bb803a42ac | ||
|
|
09de192b77 | ||
|
|
8e98019b6a | ||
|
|
fb0d04c834 | ||
|
|
1c6676cd57 | ||
|
|
ed7269518f | ||
|
|
b5c38b1095 | ||
|
|
8165db758b | ||
|
|
b3ae50c71c | ||
|
|
c3386d34d2 | ||
|
|
9df3e9b617 | ||
|
|
4c36436fb4 | ||
|
|
d3fc6c0cc7 |
4
.github/labeler.yml
vendored
4
.github/labeler.yml
vendored
@@ -314,3 +314,7 @@
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/xiaomi/**"
|
||||
"extensions: fal":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/fal/**"
|
||||
|
||||
188
.github/workflows/ci.yml
vendored
188
.github/workflows/ci.yml
vendored
@@ -304,6 +304,194 @@ jobs:
|
||||
- name: Enforce safe external URL opening policy
|
||||
run: pnpm lint:ui:no-raw-window-open
|
||||
|
||||
plugin-extension-boundary:
|
||||
name: "plugin-extension-boundary"
|
||||
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
|
||||
env:
|
||||
PLUGIN_EXTENSION_BOUNDARY_ENFORCE_AFTER: "2026-03-24T05:00:00Z"
|
||||
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: Run plugin extension boundary guard with grace period
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
tmp_output="$(mktemp)"
|
||||
if pnpm run lint:plugins:no-extension-imports >"$tmp_output" 2>&1; then
|
||||
cat "$tmp_output"
|
||||
rm -f "$tmp_output"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
status=$?
|
||||
cat "$tmp_output"
|
||||
rm -f "$tmp_output"
|
||||
|
||||
now_epoch="$(date -u +%s)"
|
||||
enforce_epoch="$(date -u -d "$PLUGIN_EXTENSION_BOUNDARY_ENFORCE_AFTER" +%s)"
|
||||
fix_instructions="If you are an LLM agent fixing this: run 'pnpm run lint:plugins:no-extension-imports', remove src/plugins/** -> extensions/** imports where possible, and if the remaining inventory is intentional for now update test/fixtures/plugin-extension-import-boundary-inventory.json in the same PR."
|
||||
|
||||
if [ "$now_epoch" -lt "$enforce_epoch" ]; then
|
||||
echo "::warning::Plugin extension import boundary violations are temporarily allowed until ${PLUGIN_EXTENSION_BOUNDARY_ENFORCE_AFTER}. This grace period ends in one week from the rollout date. After that timestamp this job will fail unless the inventory is reduced or the baseline is intentionally updated. ${fix_instructions}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::error::Plugin extension import boundary grace period ended at ${PLUGIN_EXTENSION_BOUNDARY_ENFORCE_AFTER}. ${fix_instructions}"
|
||||
exit "$status"
|
||||
|
||||
web-search-provider-boundary:
|
||||
name: "web-search-provider-boundary"
|
||||
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
|
||||
env:
|
||||
WEB_SEARCH_PROVIDER_BOUNDARY_ENFORCE_AFTER: "2026-03-24T05:00:00Z"
|
||||
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: Run web search provider boundary guard with grace period
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
tmp_output="$(mktemp)"
|
||||
if pnpm run lint:web-search-provider-boundaries >"$tmp_output" 2>&1; then
|
||||
cat "$tmp_output"
|
||||
rm -f "$tmp_output"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
status=$?
|
||||
cat "$tmp_output"
|
||||
rm -f "$tmp_output"
|
||||
|
||||
now_epoch="$(date -u +%s)"
|
||||
enforce_epoch="$(date -u -d "$WEB_SEARCH_PROVIDER_BOUNDARY_ENFORCE_AFTER" +%s)"
|
||||
fix_instructions="If you are an LLM agent fixing this: run 'pnpm run lint:web-search-provider-boundaries', move provider-specific web-search logic out of core, and if the remaining inventory is intentional for now update test/fixtures/web-search-provider-boundary-inventory.json in the same PR."
|
||||
|
||||
if [ "$now_epoch" -lt "$enforce_epoch" ]; then
|
||||
echo "::warning::Web search provider boundary violations are temporarily allowed until ${WEB_SEARCH_PROVIDER_BOUNDARY_ENFORCE_AFTER}. This grace period ends in one week from the rollout date. After that timestamp this job will fail unless the inventory is reduced or the baseline is intentionally updated. ${fix_instructions}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::error::Web search provider boundary grace period ended at ${WEB_SEARCH_PROVIDER_BOUNDARY_ENFORCE_AFTER}. ${fix_instructions}"
|
||||
exit "$status"
|
||||
|
||||
extension-src-outside-plugin-sdk-boundary:
|
||||
name: "extension-src-outside-plugin-sdk-boundary"
|
||||
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
|
||||
env:
|
||||
EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER: "2026-03-24T05:00:00Z"
|
||||
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: Run extension src boundary guard with grace period
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
tmp_output="$(mktemp)"
|
||||
if pnpm run lint:extensions:no-src-outside-plugin-sdk >"$tmp_output" 2>&1; then
|
||||
cat "$tmp_output"
|
||||
rm -f "$tmp_output"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
status=$?
|
||||
cat "$tmp_output"
|
||||
rm -f "$tmp_output"
|
||||
|
||||
now_epoch="$(date -u +%s)"
|
||||
enforce_epoch="$(date -u -d "$EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER" +%s)"
|
||||
fix_instructions="If you are an LLM agent fixing this: run 'pnpm run lint:extensions:no-src-outside-plugin-sdk', move extension imports off core src paths and onto src/plugin-sdk/**, and if the remaining inventory is intentional for now update test/fixtures/extension-src-outside-plugin-sdk-inventory.json in the same PR."
|
||||
|
||||
if [ "$now_epoch" -lt "$enforce_epoch" ]; then
|
||||
echo "::warning::Extension src boundary violations are temporarily allowed until ${EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER}. This grace period ends in one week from the rollout date. After that timestamp this job will fail unless the inventory is reduced or the baseline is intentionally updated. ${fix_instructions}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::error::Extension src boundary grace period ended at ${EXTENSION_PLUGIN_SDK_BOUNDARY_ENFORCE_AFTER}. ${fix_instructions}"
|
||||
exit "$status"
|
||||
|
||||
extension-plugin-sdk-internal-boundary:
|
||||
name: "extension-plugin-sdk-internal-boundary"
|
||||
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
|
||||
env:
|
||||
EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER: "2026-03-24T05:00:00Z"
|
||||
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: Run extension plugin-sdk-internal guard with grace period
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
tmp_output="$(mktemp)"
|
||||
if pnpm run lint:extensions:no-plugin-sdk-internal >"$tmp_output" 2>&1; then
|
||||
cat "$tmp_output"
|
||||
rm -f "$tmp_output"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
status=$?
|
||||
cat "$tmp_output"
|
||||
rm -f "$tmp_output"
|
||||
|
||||
now_epoch="$(date -u +%s)"
|
||||
enforce_epoch="$(date -u -d "$EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER" +%s)"
|
||||
fix_instructions="If you are an LLM agent fixing this: run 'pnpm run lint:extensions:no-plugin-sdk-internal', remove extension imports of src/plugin-sdk-internal/** in favor of src/plugin-sdk/**, and if the remaining inventory is intentional for now update test/fixtures/extension-plugin-sdk-internal-inventory.json in the same PR."
|
||||
|
||||
if [ "$now_epoch" -lt "$enforce_epoch" ]; then
|
||||
echo "::warning::Extension plugin-sdk-internal boundary violations are temporarily allowed until ${EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER}. This grace period ends in one week from the rollout date. After that timestamp this job will fail unless the inventory is reduced or the baseline is intentionally updated. ${fix_instructions}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::error::Extension plugin-sdk-internal boundary grace period ended at ${EXTENSION_PLUGIN_SDK_INTERNAL_ENFORCE_AFTER}. ${fix_instructions}"
|
||||
exit "$status"
|
||||
|
||||
build-smoke:
|
||||
name: "build-smoke"
|
||||
needs: [docs-scope, changed-scope]
|
||||
|
||||
@@ -114,6 +114,7 @@
|
||||
- Never add `@ts-nocheck` and do not disable `no-explicit-any`; fix root causes and update Oxlint/Oxfmt config only when required.
|
||||
- Dynamic import guardrail: do not mix `await import("x")` and static `import ... from "x"` for the same module in production code paths. If you need lazy loading, create a dedicated `*.runtime.ts` boundary (that re-exports from `x`) and dynamically import that boundary from lazy callers only.
|
||||
- Dynamic import verification: after refactors that touch lazy-loading/module boundaries, run `pnpm build` and check for `[INEFFECTIVE_DYNAMIC_IMPORT]` warnings before submitting.
|
||||
- Extension SDK self-import guardrail: inside an extension package, do not import that same extension via `openclaw/plugin-sdk/<extension>` from production files. Route internal imports through a local barrel such as `./api.ts` or `./runtime-api.ts`, and keep the `plugin-sdk/<extension>` path as the external contract only.
|
||||
- Never share class behavior via prototype mutation (`applyPrototypeMixins`, `Object.defineProperty` on `.prototype`, or exporting `Class.prototype` for merges). Use explicit inheritance/composition (`A extends B extends C`) or helper composition so TypeScript can typecheck.
|
||||
- If this pattern is needed, stop and get explicit approval before shipping; default behavior is to split/refactor into an explicit class hierarchy and keep members strongly typed.
|
||||
- In tests, prefer per-instance stubs over prototype mutation (`SomeClass.prototype.method = ...`) unless a test explicitly documents why prototype-level patching is required.
|
||||
|
||||
266
CHANGELOG.md
266
CHANGELOG.md
@@ -39,15 +39,13 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/Chutes: add a bundled Chutes provider with plugin-owned OAuth/API-key auth, dynamic model discovery, and default-on extension wiring. (#41416) Thanks @Veightor.
|
||||
- Plugins/binding: add `onConversationBindingResolved(...)` so plugins can react immediately after bind approvals or denies without blocking channel interaction acknowledgements. (#48678) Thanks @huntharo.
|
||||
- CLI/config: expand `config set` with SecretRef and provider builder modes, JSON/batch assignment support, and `--dry-run` validation with structured JSON output. (#49296) Thanks @joshavant.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc.
|
||||
- Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root.
|
||||
- Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly.
|
||||
- Control UI/appearance: unify theme border radii across Claw, Knot, and Dash, and add a Roundness slider to the Appearance settings so users can adjust corner radius from sharp to fully rounded. Thanks @BunsDev.
|
||||
- Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev.
|
||||
- Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev.
|
||||
- Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli.
|
||||
- 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. (#47560) Thanks @ngutman.
|
||||
- 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.
|
||||
@@ -128,9 +126,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram/network: preserve sticky IPv4 fallback state across polling restarts so hosts with unstable IPv6 to `api.telegram.org` stop re-triggering repeated Telegram timeouts after each restart. (#48282) Thanks @yassinebkr.
|
||||
- Plugins/subagents: forward per-run provider and model overrides through gateway plugin subagent dispatch so plugin-launched agent delegations honor explicit model selection again. (#48277) Thanks @jalehman.
|
||||
- Agents/compaction: write minimal boundary summaries for empty preparations while keeping split-turn prefixes on the normal path, so no-summarizable-message sessions stop retriggering the safeguard loop. (#42215) thanks @lml2468.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Agents/bootstrap warnings: move bootstrap truncation warnings out of the system prompt and into the per-turn prompt body so prompt-cache reuse stays stable when truncation warnings appear or disappear. (#48753) Thanks @scoootscooob and @obviyus.
|
||||
- Telegram/DM topic session keys: route named-account DM topics through the same per-account base session key across inbound messages, native commands, and session-state lookups so `/status` and thread recovery stop creating phantom `agent:main:main:thread:...` sessions. (#48204) Thanks @vincentkoc.
|
||||
- macOS/node service startup: use `openclaw node start/stop --json` from the Mac app instead of the removed `openclaw service node ...` command shape, so current CLI installs expose the full node exec surface again. (#46843) Fixes #43171. Thanks @Br1an67.
|
||||
@@ -140,6 +135,18 @@ Docs: https://docs.openclaw.ai
|
||||
- Mattermost/DM send: retry transient direct-channel creation failures for DM deliveries, with configurable backoff and per-request timeout. (#42398) Thanks @JonathanJing.
|
||||
- Telegram/network: unify API and media fetches under the same sticky IPv4 and pinned-IP fallback chain, and re-validate pinned override addresses against SSRF policy. (#49148) Thanks @obviyus.
|
||||
- Agents/prompt composition: append bootstrap truncation warnings to the current-turn prompt and add regression coverage for stable system-prompt cache invariants. (#49237) Thanks @scoootscooob.
|
||||
- Gateway/auth: add regression coverage that keeps device-less trusted-proxy Control UI sessions off privileged pairing approval RPCs. Thanks @vincentkoc.
|
||||
- Plugins/runtime-api: pin extension runtime-api export seams with explicit guardrail coverage so future surface creep becomes a deliberate diff. Thanks @vincentkoc.
|
||||
- Telegram/security: add regression coverage proving pinned fallback host overrides stay bound to Telegram and delegate non-matching hostnames back to the original lookup path. Thanks @vincentkoc.
|
||||
- Secrets/exec refs: require explicit `--allow-exec` for `secrets apply` write plans that contain exec SecretRefs/providers, and align audit/configure/apply dry-run behavior to skip exec checks unless opted in to prevent unexpected command side effects. (#49417) Thanks @restriction and @joshavant.
|
||||
- Tools/image generation: add bundled fal image generation support so `image_generate` can target `fal/*` models with `FAL_KEY`, including single-image edit flows via FLUX image-to-image. Thanks @vincentkoc.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Browser/Chrome MCP: remove the legacy Chrome extension relay path, bundled extension assets, `driver: "extension"`, and `browser.relayBindHost`. Run `openclaw doctor --fix` to migrate host-local browser config to `existing-session` / `user`; Docker, headless, sandbox, and remote browser flows still use raw CDP. (#47893) Thanks @vincentkoc.
|
||||
- Plugins/runtime: remove the public `openclaw/extension-api` surface with no compatibility shim. Bundled plugins must use injected runtime for host-side operations (for example `api.runtime.agent.runEmbeddedPiAgent`) and any remaining direct imports must come from narrow `openclaw/plugin-sdk/*` subpaths instead of the monolithic SDK root.
|
||||
- Tools/image generation: standardize the stock image create/edit path on the core `image_generate` tool. The old `nano-banana-pro` docs/examples are gone; if you previously copied that sample-skill config, switch to `agents.defaults.imageGenerationModel` for built-in image generation or install a separate third-party skill explicitly.
|
||||
- Plugins/message discovery: require `ChannelMessageActionAdapter.describeMessageTool(...)` for shared `message` tool discovery. The legacy `listActions`, `getCapabilities`, and `getToolSchema` adapter methods are removed. Plugin authors should migrate message discovery to `describeMessageTool(...)` and keep channel-specific action runtime code inside the owning plugin package. Thanks @gumadeiras.
|
||||
|
||||
## 2026.3.13
|
||||
|
||||
@@ -155,10 +162,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron/sessions: add `sessionTarget: "current"` and `session:<id>` support so cron jobs can bind to the creating session or a persistent named session instead of only `main` or `isolated`. Thanks @kkhomej33-netizen and @ImLukeF.
|
||||
- Telegram/message send: add `--force-document` so Telegram image and GIF sends can upload as documents without compression. (#45111) Thanks @thepagent.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Agents now load at most one root memory bootstrap file. `MEMORY.md` wins; `memory.md` is only used when `MEMORY.md` is absent. If you intentionally kept both files and depended on both being injected, merge them before upgrade. This also fixes duplicate memory injection on case-insensitive Docker mounts. (#26054) Thanks @Lanfei.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Dashboard/chat UI: stop reloading full chat history on every live tool result in dashboard v2 so tool-heavy runs no longer trigger UI freeze/re-render storms while the final event still refreshes persisted history. (#45541) Thanks @BunsDev.
|
||||
@@ -218,8 +221,13 @@ Docs: https://docs.openclaw.ai
|
||||
- Auth/login lockout recovery: clear stale `auth_permanent` and `billing` disabled state for all profiles matching the target provider when `openclaw models auth login` is invoked, so users locked out by expired or revoked OAuth tokens can recover by re-authenticating instead of waiting for the cooldown timer to expire. (#43057)
|
||||
- Auto-reply/context-engine compaction: persist the exact embedded-run metadata compaction count for main and followup runner session accounting, so metadata-only auto-compactions no longer undercount multi-compaction runs. (#42629) thanks @uf-hy.
|
||||
- Auth/Codex CLI reuse: sync reused Codex CLI credentials into the supported `openai-codex:default` OAuth profile instead of reviving the deprecated `openai-codex:codex-cli` slot, so doctor cleanup no longer loops. (#45353) thanks @Gugu-sugar.
|
||||
- Deps/audit: bump the pinned `fast-xml-parser` override to the first patched release so `pnpm audit --prod --audit-level=high` no longer fails on the AWS Bedrock XML builder path. Thanks @vincentkoc.
|
||||
- Hooks/after_compaction: forward `sessionFile` for direct/manual compaction events and add `sessionFile` plus `sessionKey` to wired auto-compaction hook context so plugins receive the session metadata already declared in the hook types. (#40781) Thanks @jarimustonen.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Agents now load at most one root memory bootstrap file. `MEMORY.md` wins; `memory.md` is only used when `MEMORY.md` is absent. If you intentionally kept both files and depended on both being injected, merge them before upgrade. This also fixes duplicate memory injection on case-insensitive Docker mounts. (#26054) Thanks @Lanfei.
|
||||
|
||||
## 2026.3.12
|
||||
|
||||
### Changes
|
||||
@@ -320,10 +328,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.3.11
|
||||
|
||||
### Security
|
||||
|
||||
- Gateway/WebSocket: enforce browser origin validation for all browser-originated connections regardless of whether proxy headers are present, closing a cross-site WebSocket hijacking path in `trusted-proxy` mode that could grant untrusted origins `operator.admin` access. (GHSA-5wcw-8jjv-m286)
|
||||
|
||||
### Changes
|
||||
|
||||
- OpenRouter/models: add temporary Hunter Alpha and Healer Alpha entries to the built-in catalog so OpenRouter users can try the new free stealth models during their roughly one-week availability window. (#43642) Thanks @ping-Toven.
|
||||
@@ -345,10 +349,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Mattermost/reply threading: add `channels.mattermost.replyToMode` for channel and group messages so top-level posts can start thread-scoped sessions without the manual reply-then-thread workaround. (#29587) Thanks @teconomix.
|
||||
- iOS/push relay: add relay-backed official-build push delivery with App Attest + receipt verification, gateway-bound send delegation, and config-based relay URL setup on the gateway. (#43369) Thanks @ngutman.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Cron/doctor: tighten isolated cron delivery so cron jobs can no longer notify through ad hoc agent sends or fallback main-session summaries, and add `openclaw doctor --fix` migration for legacy cron storage and legacy notify/webhook delivery metadata. (#40998) Thanks @mbelinky.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Windows/install: stop auto-installing `node-llama-cpp` during normal npm CLI installs so `openclaw@latest` no longer fails on Windows while building optional local-embedding dependencies.
|
||||
@@ -461,6 +461,14 @@ Docs: https://docs.openclaw.ai
|
||||
- Feishu/streaming recovery: clear stale `streamingStartPromise` when card creation fails (HTTP 400) so subsequent messages can retry streaming instead of silently dropping all future replies. Fixes #43322.
|
||||
- Exec/env sandbox: block JVM agent injection (`JAVA_TOOL_OPTIONS`, `_JAVA_OPTIONS`, `JDK_JAVA_OPTIONS`), Python breakpoint hijack (`PYTHONBREAKPOINT`), and .NET startup hooks (`DOTNET_STARTUP_HOOKS`) from the host exec environment. (#49025)
|
||||
|
||||
### Security
|
||||
|
||||
- Gateway/WebSocket: enforce browser origin validation for all browser-originated connections regardless of whether proxy headers are present, closing a cross-site WebSocket hijacking path in `trusted-proxy` mode that could grant untrusted origins `operator.admin` access. (GHSA-5wcw-8jjv-m286)
|
||||
|
||||
### Breaking
|
||||
|
||||
- Cron/doctor: tighten isolated cron delivery so cron jobs can no longer notify through ad hoc agent sends or fallback main-session summaries, and add `openclaw doctor --fix` migration for legacy cron storage and legacy notify/webhook delivery metadata. (#40998) Thanks @mbelinky.
|
||||
|
||||
## 2026.3.8
|
||||
|
||||
### Changes
|
||||
@@ -573,10 +581,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Google/Gemini 3.1 Flash-Lite: add first-class `google/gemini-3.1-flash-lite-preview` support across model-id normalization, default aliases, media-understanding image lookups, Google Gemini CLI forward-compat fallback, and docs.
|
||||
- Agents/compaction model override: allow `agents.defaults.compaction.model` to route compaction summarization through a different model than the main session, and document the override across config help/reference surfaces. (#38753) thanks @starbuck100.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Gateway auth now requires explicit `gateway.auth.mode` when both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs). Set `gateway.auth.mode` to `token` or `password` before upgrade to avoid startup/pairing/TUI failures. (#35094) Thanks @joshavant.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Models/MiniMax: stop advertising removed `MiniMax-M2.5-Lightning` in built-in provider catalogs, onboarding metadata, and docs; keep the supported fast-tier model as `MiniMax-M2.5-highspeed`.
|
||||
@@ -902,6 +906,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Mattermost/DM media uploads: resolve bare 26-character Mattermost IDs user-first for direct messages so media sends no longer fail with `403 Forbidden` when targets are configured as unprefixed user IDs. (#29925) Thanks @teconomix.
|
||||
- Voice-call/OpenAI TTS config parity: add missing `speed`, `instructions`, and `baseUrl` fields to the OpenAI TTS config schema and gate `instructions` to supported models so voice-call overrides validate and route cleanly through core TTS. (#39226) Thanks @ademczuk.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Gateway auth now requires explicit `gateway.auth.mode` when both `gateway.auth.token` and `gateway.auth.password` are configured (including SecretRefs). Set `gateway.auth.mode` to `token` or `password` before upgrade to avoid startup/pairing/TUI failures. (#35094) Thanks @joshavant.
|
||||
|
||||
## 2026.3.2
|
||||
|
||||
### Changes
|
||||
@@ -930,13 +938,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/input_image MIME validation: sniff uploaded image bytes before MIME allowlist enforcement again so declared image types cannot mask concrete non-image payloads, while keeping HEIC/HEIF normalization behavior scoped to actual HEIC inputs. Thanks @vincentkoc.
|
||||
- Zalo Personal plugin (`@openclaw/zalouser`): keep canonical DM routing while preserving legacy DM session continuity on upgrade, and preserve provider-native `g-`/`u-` target ids in outbound send and directory flows so #33992 lands without breaking existing sessions or stored targets. (#33992) Thanks @darkamenosa.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Onboarding now defaults `tools.profile` to `messaging` for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.
|
||||
- **BREAKING:** ACP dispatch now defaults to enabled unless explicitly disabled (`acp.dispatch.enabled=false`). If you need to pause ACP turn routing while keeping `/acp` controls, set `acp.dispatch.enabled=false`. Docs: https://docs.openclaw.ai/tools/acp-agents
|
||||
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
|
||||
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Feishu/Outbound render mode: respect Feishu account `renderMode` in outbound sends so card mode (and auto-detected markdown tables/code blocks) uses markdown card delivery instead of always sending plain text. (#31562) Thanks @arkyu2077.
|
||||
@@ -1123,6 +1124,13 @@ Docs: https://docs.openclaw.ai
|
||||
- Tests/Subagent announce: set `OPENCLAW_TEST_FAST=1` before importing `subagent-announce` format suites so module-level fast-mode constants are captured deterministically on Windows CI, preventing timeout flakes in nested completion announce coverage. (#31370) Thanks @zwffff.
|
||||
- Control UI/markdown recursion fallback: catch markdown parser failures and safely render escaped plain-text fallback instead of crashing the Control UI on pathological markdown history payloads. (#36445, fixes #36213) Thanks @BinHPdev.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Onboarding now defaults `tools.profile` to `messaging` for new local installs (interactive + non-interactive). New setups no longer start with broad coding/system tools unless explicitly configured.
|
||||
- **BREAKING:** ACP dispatch now defaults to enabled unless explicitly disabled (`acp.dispatch.enabled=false`). If you need to pause ACP turn routing while keeping `/acp` controls, set `acp.dispatch.enabled=false`. Docs: https://docs.openclaw.ai/tools/acp-agents
|
||||
- **BREAKING:** Plugin SDK removed `api.registerHttpHandler(...)`. Plugins must register explicit HTTP routes via `api.registerHttpRoute({ path, auth, match, handler })`, and dynamic webhook lifecycles should use `registerPluginHttpRoute(...)`.
|
||||
- **BREAKING:** Zalo Personal plugin (`@openclaw/zalouser`) no longer depends on external `zca`-compatible CLI binaries (`openzca`, `zca-cli`) for runtime send/listen/login; operators should use `openclaw channels login --channel zalouser` after upgrade to refresh sessions in the new JS-native path.
|
||||
|
||||
## 2026.3.1
|
||||
|
||||
### Changes
|
||||
@@ -1150,11 +1158,6 @@ Docs: https://docs.openclaw.ai
|
||||
- OpenAI/WebSocket warm-up: add optional OpenAI Responses WebSocket warm-up (`response.create` with `generate:false`), enable it by default for `openai/*`, and expose `params.openaiWsWarmup` for per-model enable/disable control.
|
||||
- Agents/Subagents runtime events: replace ad-hoc subagent completion system-message handoff with typed internal completion events (`task_completion`) that are rendered consistently across direct and queued announce paths, with gateway/CLI plumbing for structured `internalEvents`.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
|
||||
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Feishu/Streaming card text fidelity: merge throttled/fragmented partial updates without dropping content and avoid newline injection when stitching chunk-style deltas so card-stream output matches final reply text. (#29616) Thanks @HaoHuaqing.
|
||||
@@ -1249,6 +1252,11 @@ Docs: https://docs.openclaw.ai
|
||||
- Signal/Sync message null-handling: treat `syncMessage` presence (including `null`) as sync envelope traffic so replayed sentTranscript payloads cannot bypass loop guards after daemon restart. Landed from contributor PR #31138 by @Sid-Qin. Thanks @Sid-Qin.
|
||||
- Infra/fs-safe: sanitize directory-read failures so raw `EISDIR` text never leaks to messaging surfaces, with regression tests for both root-scoped and direct safe reads. Landed from contributor PR #31205 by @polooooo. Thanks @polooooo.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Node exec approval payloads now require `systemRunPlan`. `host=node` approval requests without that plan are rejected.
|
||||
- **BREAKING:** Node `system.run` execution now pins path-token commands to the canonical executable path (`realpath`) in both allowlist and approval execution flows. Integrations/tests that asserted token-form argv (for example `tr`) must now accept canonical paths (for example `/usr/bin/tr`).
|
||||
|
||||
## 2026.2.27
|
||||
|
||||
### Changes
|
||||
@@ -1524,10 +1532,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Config: remind agents to call `config.schema` before config edits or config-field questions to avoid guessing. Thanks @thewilloftheshadow.
|
||||
- Dependencies: update workspace dependency pins and lockfile (Bedrock SDK `3.998.0`, `@mariozechner/pi-*` `0.55.1`, TypeScript native preview `7.0.0-dev.20260225.1`) while keeping `@buape/carbon` pinned.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Heartbeat direct/DM delivery default is now `allow` again. To keep DM-blocked behavior from `2026.2.24`, set `agents.defaults.heartbeat.directPolicy: "block"` (or per-agent override).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Slack/Identity: thread agent outbound identity (`chat:write.customize` overrides) through the channel reply delivery path so per-agent username, icon URL, and icon emoji are applied to all Slack replies including media messages. (#27134) Thanks @hou-rong.
|
||||
@@ -1591,6 +1595,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Tests/Low-memory stability: disable Vitest `vmForks` by default on low-memory local hosts (`<64 GiB`), keep low-profile extension lane parallelism at 4 workers, and align cron isolated-agent tests with `setSessionRuntimeModel` usage to avoid deterministic suite failures. (#26324) Thanks @ngutman.
|
||||
- Feishu/WebSocket proxy: pass a proxy agent to Feishu WS clients from standard proxy environment variables and include plugin-local runtime dependency wiring so websocket mode works in proxy-constrained installs. (#26397) Thanks @colin719.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Heartbeat direct/DM delivery default is now `allow` again. To keep DM-blocked behavior from `2026.2.24`, set `agents.defaults.heartbeat.directPolicy: "block"` (or per-agent override).
|
||||
|
||||
## 2026.2.24
|
||||
|
||||
### Changes
|
||||
@@ -1601,11 +1609,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Security/Audit: add `security.trust_model.multi_user_heuristic` to flag likely shared-user ingress and clarify the personal-assistant trust model, with hardening guidance for intentional multi-user setups (`sandbox.mode="all"`, workspace-scoped FS, reduced tool surface, no personal/private identities on shared runtimes).
|
||||
- Dependencies: refresh key runtime and tooling packages across the workspace (Bedrock SDK, pi runtime stack, OpenAI, Google auth, and oxlint/oxfmt), while intentionally keeping `@buape/carbon` pinned.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example `user:<id>`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages.
|
||||
- **BREAKING:** Security/Sandbox: block Docker `network: "container:<id>"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Routing/Session isolation: harden followup routing so explicit cross-channel origin replies never fall back to the active dispatcher on route failure, preserve queued overflow summary routing metadata (`channel`/`to`/`thread`) across followup drain, and prefer originating channel context over internal provider tags for embedded followup runs. This prevents webchat/control-ui context from hijacking Discord-targeted replies in shared sessions. (#25864) Thanks @Gamedesigner.
|
||||
@@ -1685,6 +1688,11 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Compaction: harden summarization prompts to preserve opaque identifiers verbatim (UUIDs, IDs, tokens, host/IP/port, URLs), reducing post-compaction identifier drift and hallucinated identifier reconstruction.
|
||||
- Security/Sandbox: canonicalize bind-mount source paths via existing-ancestor realpath so symlink-parent + non-existent-leaf paths cannot bypass allowed-source-roots or blocked-path checks. Thanks @tdjackey.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Heartbeat delivery now blocks direct/DM targets when destination parsing identifies a direct chat (for example `user:<id>`, Telegram user chat IDs, or WhatsApp direct numbers/JIDs). Heartbeat runs still execute, but direct-message delivery is skipped and only non-DM destinations (for example channel/group targets) can receive outbound heartbeat messages.
|
||||
- **BREAKING:** Security/Sandbox: block Docker `network: "container:<id>"` namespace-join mode by default for sandbox and sandbox-browser containers. To keep that behavior intentionally, set `agents.defaults.sandbox.docker.dangerouslyAllowContainerNamespaceJoin: true` (break-glass). Thanks @tdjackey for reporting.
|
||||
|
||||
## 2026.2.23
|
||||
|
||||
### Changes
|
||||
@@ -1699,10 +1707,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/Config: support per-agent `params` overrides merged on top of model defaults (including `cacheRetention`) so mixed-traffic agents can tune cache behavior independently. (#17470, #17112) Thanks @rrenamed.
|
||||
- Agents/Bootstrap: cache bootstrap file snapshots per session key and clear them on session reset/delete, reducing prompt-cache invalidations from in-session `AGENTS.md`/`MEMORY.md` writes. (#22220) Thanks @anisoptera.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** browser SSRF policy now defaults to trusted-network mode (`browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=true` when unset), and canonical config uses `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` instead of `browser.ssrfPolicy.allowPrivateNetwork`. `openclaw doctor --fix` migrates the legacy key automatically.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Security/Config: redact sensitive-looking dynamic catchall keys in `config.get` snapshots (for example `env.*` and `skills.entries.*.env.*`) and preserve round-trip restore behavior for those redacted sentinels. Thanks @merc1305.
|
||||
@@ -1748,6 +1752,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Skills/Python: harden skill script packaging and validation edge cases (self-including `.skill` outputs, CRLF frontmatter parsing, strict `--days` validation, and safer image file loading), with expanded Python regression coverage. Thanks @vincentkoc.
|
||||
- Skills/Python: add CI + pre-commit linting (`ruff`) and pytest discovery coverage for Python scripts/tests under `skills/`, including package test execution from repo root. Thanks @vincentkoc.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** browser SSRF policy now defaults to trusted-network mode (`browser.ssrfPolicy.dangerouslyAllowPrivateNetwork=true` when unset), and canonical config uses `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` instead of `browser.ssrfPolicy.allowPrivateNetwork`. `openclaw doctor --fix` migrates the legacy key automatically.
|
||||
|
||||
## 2026.2.22
|
||||
|
||||
### Changes
|
||||
@@ -1772,14 +1780,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Skills: remove bundled `food-order` skill from this repo; manage/install it from ClawHub instead.
|
||||
- Docs/Subagents: make thread-bound session guidance channel-first instead of Discord-specific, and list thread-supporting channels explicitly. (#23589) Thanks @osolmaz.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** removed Google Antigravity provider support and the bundled `google-antigravity-auth` plugin. Existing `google-antigravity/*` model/profile configs no longer work; migrate to `google-gemini-cli` or other supported providers.
|
||||
- **BREAKING:** tool-failure replies now hide raw error details by default. OpenClaw still sends a failure summary, but detailed error suffixes (for example provider/runtime messages and local path fragments) now require `/verbose on` or `/verbose full`.
|
||||
- **BREAKING:** CLI local onboarding now sets `session.dmScope` to `per-channel-peer` by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set `session.dmScope` to `main`. (#23468) Thanks @bmendonca3.
|
||||
- **BREAKING:** unify channel preview-streaming config to `channels.<channel>.streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names.
|
||||
- **BREAKING:** remove legacy Gateway device-auth signature `v1`. Device-auth clients must now sign `v2` payloads with the per-connection `connect.challenge` nonce and send `device.nonce`; nonce-less connects are rejected.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Sessions/Resilience: ignore invalid persisted `sessionFile` metadata and fall back to the derived safe transcript path instead of aborting session resolution for handlers and tooling. (#16061) Thanks @haoyifan and @vincentkoc.
|
||||
@@ -2006,6 +2006,14 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/Daemon: verify gateway health after daemon restart.
|
||||
- Agents/UI text: stop rewriting normal assistant billing/payment language outside explicit error contexts. (#17834) Thanks @niceysam.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** removed Google Antigravity provider support and the bundled `google-antigravity-auth` plugin. Existing `google-antigravity/*` model/profile configs no longer work; migrate to `google-gemini-cli` or other supported providers.
|
||||
- **BREAKING:** tool-failure replies now hide raw error details by default. OpenClaw still sends a failure summary, but detailed error suffixes (for example provider/runtime messages and local path fragments) now require `/verbose on` or `/verbose full`.
|
||||
- **BREAKING:** CLI local onboarding now sets `session.dmScope` to `per-channel-peer` by default for new/implicit DM scope configuration. If you depend on shared DM continuity across senders, explicitly set `session.dmScope` to `main`. (#23468) Thanks @bmendonca3.
|
||||
- **BREAKING:** unify channel preview-streaming config to `channels.<channel>.streaming` with enum values `off | partial | block | progress`, and move Slack native stream toggle to `channels.slack.nativeStreaming`. Legacy keys (`streamMode`, Slack boolean `streaming`) are still read and migrated by `openclaw doctor --fix`, but canonical saved config/docs now use the unified names.
|
||||
- **BREAKING:** remove legacy Gateway device-auth signature `v1`. Device-auth clients must now sign `v2` payloads with the per-connection `connect.challenge` nonce and send `device.nonce`; nonce-less connects are rejected.
|
||||
|
||||
## 2026.2.21
|
||||
|
||||
### Changes
|
||||
@@ -2654,10 +2662,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Onboarding/Providers: add first-class Hugging Face Inference provider support (provider wiring, onboarding auth choice/API key flow, and default-model selection), and preserve Hugging Face auth intent in auth-choice remapping (`tokenProvider=huggingface` with `authChoice=apiKey`) while skipping env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp.
|
||||
- Onboarding/Providers: add `minimax-api-key-cn` auth choice for the MiniMax China API endpoint. (#15191) Thanks @liuy.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Config/State: removed legacy `.moltbot` auto-detection/migration and `moltbot.json` config candidates. If you still have state/config under `~/.moltbot`, move it to `~/.openclaw` (recommended) or set `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH` explicitly.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/Auth: add trusted-proxy mode hardening follow-ups by keeping `OPENCLAW_GATEWAY_*` env compatibility, auto-normalizing invalid setup combinations in interactive `gateway configure` (trusted-proxy forces `bind=lan` and disables Tailscale serve/funnel), and suppressing shared-secret/rate-limit audit findings that do not apply to trusted-proxy deployments. (#15940) Thanks @nickytonline.
|
||||
@@ -2760,6 +2764,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.
|
||||
- Security/Pairing: generate 256-bit base64url device and node pairing tokens and use byte-safe constant-time verification to avoid token-compare edge-case failures. (#16535) Thanks @FaizanKolega, @gumadeiras.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Config/State: removed legacy `.moltbot` auto-detection/migration and `moltbot.json` config candidates. If you still have state/config under `~/.moltbot`, move it to `~/.openclaw` (recommended) or set `OPENCLAW_STATE_DIR` / `OPENCLAW_CONFIG_PATH` explicitly.
|
||||
|
||||
## 2026.2.12
|
||||
|
||||
### Changes
|
||||
@@ -2771,10 +2779,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Discord: add role-based allowlists and role-based agent routing. (#10650) Thanks @Minidoracat.
|
||||
- Config: avoid redacting `maxTokens`-like fields during config snapshot redaction, preventing round-trip validation failures in `/config`. (#14006) Thanks @constansino.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Hooks: `POST /hooks/agent` now rejects payload `sessionKey` overrides by default. To keep fixed hook context, set `hooks.defaultSessionKey` (recommended with `hooks.allowedSessionKeyPrefixes: ["hook:"]`). If you need legacy behavior, explicitly set `hooks.allowRequestSessionKey: true`. Thanks @alpernae for reporting.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Gateway/OpenResponses: harden URL-based `input_file`/`input_image` handling with explicit SSRF deny policy, hostname allowlists (`files.urlAllowlist` / `images.urlAllowlist`), per-request URL input caps (`maxUrlParts`), blocked-fetch audit logging, and regression coverage/docs updates.
|
||||
@@ -2857,6 +2861,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik.
|
||||
- Update/Daemon: fix post-update restart compatibility by generating `dist/cli/daemon-cli.js` with alias-aware exports from hashed daemon bundles, preventing `registerDaemonCli` import failures during `openclaw update`.
|
||||
|
||||
### Breaking
|
||||
|
||||
- Hooks: `POST /hooks/agent` now rejects payload `sessionKey` overrides by default. To keep fixed hook context, set `hooks.defaultSessionKey` (recommended with `hooks.allowedSessionKeyPrefixes: ["hook:"]`). If you need legacy behavior, explicitly set `hooks.allowRequestSessionKey: true`. Thanks @alpernae for reporting.
|
||||
|
||||
## 2026.2.9
|
||||
|
||||
### Added
|
||||
@@ -2946,6 +2954,12 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
## 2026.2.6
|
||||
|
||||
### Added
|
||||
|
||||
- Cron: run history deep-links to session chat from the dashboard. (#10776) Thanks @tyler6204.
|
||||
- Cron: per-run session keys in run log entries and default labels for cron sessions. (#10776) Thanks @tyler6204.
|
||||
- Cron: legacy payload field compatibility (`deliver`, `channel`, `to`, `bestEffortDeliver`) in schema. (#10776) Thanks @tyler6204.
|
||||
|
||||
### Changes
|
||||
|
||||
- Cron: default `wakeMode` is now `"now"` for new jobs (was `"next-heartbeat"`). (#10776) Thanks @tyler6204.
|
||||
@@ -2961,12 +2975,6 @@ Docs: https://docs.openclaw.ai
|
||||
- CI: optimize pipeline throughput (macOS consolidation, Windows perf, workflow concurrency). (#10784) Thanks @mcaxtr.
|
||||
- Agents: bump pi-mono to 0.52.7; add embedded forward-compat fallback for Opus 4.6 model ids.
|
||||
|
||||
### Added
|
||||
|
||||
- Cron: run history deep-links to session chat from the dashboard. (#10776) Thanks @tyler6204.
|
||||
- Cron: per-run session keys in run log entries and default labels for cron sessions. (#10776) Thanks @tyler6204.
|
||||
- Cron: legacy payload field compatibility (`deliver`, `channel`, `to`, `bestEffortDeliver`) in schema. (#10776) Thanks @tyler6204.
|
||||
|
||||
### Fixes
|
||||
|
||||
- TTS: add missing OpenAI voices (ballad, cedar, juniper, marin, verse) to the allowlist so they are recognized instead of silently falling back to Edge TTS. (#2393)
|
||||
@@ -3265,10 +3273,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99.
|
||||
- Docs: update exe.dev install instructions. (#https://github.com/openclaw/openclaw/pull/3047) Thanks @zackerthescar.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Skills: update session-logs paths to use ~/.openclaw. (#4502) Thanks @bonald.
|
||||
@@ -3321,6 +3325,10 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway: treat loopback + non-local Host connections as remote unless trusted proxy headers are present.
|
||||
- Onboarding: remove unsupported gateway auth "off" choice from onboarding/configure flows and CLI flags.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||
|
||||
## 2026.1.24-3
|
||||
|
||||
### Fixes
|
||||
@@ -3552,11 +3560,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.openclaw.ai/web/control-ui#insecure-http
|
||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
|
||||
@@ -3579,6 +3582,11 @@ Docs: https://docs.openclaw.ai
|
||||
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
|
||||
- Embedded runner: forward sender identity into attempt execution so Feishu doc auto-grant receives requester context again. (#32915) Thanks @cszhouwei.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.openclaw.ai/web/control-ui#insecure-http
|
||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||
|
||||
## 2026.1.20
|
||||
|
||||
### Changes
|
||||
@@ -3660,10 +3668,6 @@ Docs: https://docs.openclaw.ai
|
||||
- macOS: stop syncing Peekaboo in postinstall.
|
||||
- Swabble: use the tagged Commander Swift package release.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `openclaw doctor --fix` to repair, then update plugins (`openclaw plugins update`) if you use any.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Discovery: shorten Bonjour DNS-SD service type to `_moltbot-gw._tcp` and update discovery clients/docs.
|
||||
@@ -3762,6 +3766,10 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety. Run `openclaw doctor --fix` to repair, then update plugins (`openclaw plugins update`) if you use any.
|
||||
|
||||
## 2026.1.16-2
|
||||
|
||||
### Changes
|
||||
@@ -3780,15 +3788,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Sessions: add `session.identityLinks` for cross-platform DM session li nking. (#1033) — thanks @thewilloftheshadow. https://docs.openclaw.ai/concepts/session
|
||||
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos. https://docs.openclaw.ai/tools/web
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** `openclaw message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
|
||||
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
|
||||
- **BREAKING:** `openclaw hooks` is now `openclaw webhooks`; hooks live under `openclaw hooks`. https://docs.openclaw.ai/cli/webhooks
|
||||
- **BREAKING:** `openclaw plugins install <path>` now copies into `~/.openclaw/extensions` (use `--link` to keep path-based loading).
|
||||
|
||||
### Changes
|
||||
|
||||
- Plugins: ship bundled plugins disabled by default and allow overrides by installed versions. (#1066) — thanks @ItzR3NO.
|
||||
@@ -3880,6 +3879,15 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact.
|
||||
- Skills: fix skills watcher ignored list typing (tsc).
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** `openclaw message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
|
||||
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
|
||||
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
|
||||
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
|
||||
- **BREAKING:** `openclaw hooks` is now `openclaw webhooks`; hooks live under `openclaw hooks`. https://docs.openclaw.ai/cli/webhooks
|
||||
- **BREAKING:** `openclaw plugins install <path>` now copies into `~/.openclaw/extensions` (use `--link` to keep path-based loading).
|
||||
|
||||
## 2026.1.15
|
||||
|
||||
### Highlights
|
||||
@@ -3889,11 +3897,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Heartbeat: per-agent configuration + 24h duplicate suppression. (#980) — thanks @voidserf.
|
||||
- Security: audit warns on weak model tiers; app nodes store auth tokens encrypted (Keychain/SecurePrefs).
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@openclaw/msteams` via `openclaw plugins install @openclaw/msteams`.
|
||||
|
||||
### Changes
|
||||
|
||||
- UI/Apps: move channel/config settings to schema-driven forms and rename Connections → Channels. (#1040) — thanks @thewilloftheshadow.
|
||||
@@ -3966,6 +3969,11 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
|
||||
- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)
|
||||
|
||||
### Breaking
|
||||
|
||||
- **BREAKING:** iOS minimum version is now 18.0 to support Textual markdown rendering in native chat. (#702)
|
||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@openclaw/msteams` via `openclaw plugins install @openclaw/msteams`.
|
||||
|
||||
## 2026.1.14-1
|
||||
|
||||
### Highlights
|
||||
@@ -4102,10 +4110,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Gateway: allow Tailscale Serve identity headers to satisfy token auth; rebuild Control UI assets when protocol schema is newer. (#823) — thanks @roshanasingh4; (#786) — thanks @meaningfool.
|
||||
- Heartbeat: default `ackMaxChars` to 300 so short `HEARTBEAT_OK` replies stay internal.
|
||||
|
||||
### Installer
|
||||
|
||||
- Install: run `openclaw doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds.
|
||||
@@ -4131,6 +4135,10 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Tools/UI: harden tool input schemas for strict providers; drop null-only union variants for Gemini schema cleanup; treat `maxChars: 0` as unlimited; keep TUI last streamed response instead of "(no output)". (#782) — thanks @AbhisekBasu1; (#796) — thanks @gabriel-trigo; (#747) — thanks @thewilloftheshadow.
|
||||
- Connections UI: polish multi-account account cards. (#816) — thanks @steipete.
|
||||
|
||||
### Installer
|
||||
|
||||
- Install: run `openclaw doctor --non-interactive` after git installs/updates and nudge daemon restarts when detected.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Dependencies: bump Pi packages to 0.45.3 and refresh patched pi-ai.
|
||||
@@ -4182,15 +4190,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Gateway: require `client.id` in WebSocket connect params; use `client.instanceId` for presence de-dupe; update docs/tests.
|
||||
- macOS: remove the attach-only gateway setting; local mode now always manages launchd while still attaching to an existing gateway if present.
|
||||
|
||||
### Installer
|
||||
|
||||
- Postinstall: replace `git apply` with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests.
|
||||
- Postinstall: skip pnpm patch fallback when the new patcher is active.
|
||||
- Installer tests: add root+non-root docker smokes, CI workflow to fetch openclaw.ai scripts and run install sh/cli with onboarding skipped.
|
||||
- Installer UX: support `CLAWDBOT_NO_ONBOARD=1` for non-interactive installs; fix npm prefix on Linux and auto-install git.
|
||||
- Installer UX: add `install.sh --help` with flags/env and git install hint.
|
||||
- Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Models/Onboarding: configure MiniMax (minimax.io) via Anthropic-compatible `/anthropic` endpoint by default (keep `minimax-api` as a legacy alias).
|
||||
@@ -4229,6 +4228,15 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Sandbox/Gateway: treat `agent:<id>:main` as a main-session alias when `session.mainKey` is customized (backwards compatible).
|
||||
- Auto-reply: fast-path allowlisted slash commands (inline `/help`/`/commands`/`/status`/`/whoami` stripped before model).
|
||||
|
||||
### Installer
|
||||
|
||||
- Postinstall: replace `git apply` with builtin JS patcher (works npm/pnpm/bun; no git dependency) plus regression tests.
|
||||
- Postinstall: skip pnpm patch fallback when the new patcher is active.
|
||||
- Installer tests: add root+non-root docker smokes, CI workflow to fetch openclaw.ai scripts and run install sh/cli with onboarding skipped.
|
||||
- Installer UX: support `CLAWDBOT_NO_ONBOARD=1` for non-interactive installs; fix npm prefix on Linux and auto-install git.
|
||||
- Installer UX: add `install.sh --help` with flags/env and git install hint.
|
||||
- Installer UX: add `--install-method git|npm` and auto-detect source checkouts (prompt to update git checkout vs migrate to npm).
|
||||
|
||||
## 2026.1.10
|
||||
|
||||
### Highlights
|
||||
@@ -4337,11 +4345,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Auto-reply + status: block-streaming controls, reasoning handling, usage/cost reporting.
|
||||
- Control UI/TUI: queued messages, session links, reasoning view, mobile polish, logs UX.
|
||||
|
||||
### Breaking
|
||||
|
||||
- CLI: `openclaw message` now subcommands (`message send|poll|...`) and requires `--provider` unless only one provider configured.
|
||||
- Commands/Tools: `/restart` and gateway restart tool disabled by default; enable with `commands.restart=true`.
|
||||
|
||||
### New Features and Changes
|
||||
|
||||
- Models/Auth: OpenCode Zen onboarding (#623) — thanks @magimetal; MiniMax Anthropic-compatible API + hosted onboarding (#590, #495) — thanks @mneves75, @tobiasbischoff.
|
||||
@@ -4383,6 +4386,11 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Onboarding/Configure: QuickStart single-select provider picker; avoid Codex CLI false-expiry warnings; clarify WhatsApp owner prompt; fix Minimax hosted onboarding (agents.defaults + msteams heartbeat target); remove configure Control UI prompt; honor gateway --dev flag.
|
||||
- Agent loop: guard overflow compaction throws and restore compaction hooks for engine-owned context engines. (#41361) — thanks @davidrudduck
|
||||
|
||||
### Breaking
|
||||
|
||||
- CLI: `openclaw message` now subcommands (`message send|poll|...`) and requires `--provider` unless only one provider configured.
|
||||
- Commands/Tools: `/restart` and gateway restart tool disabled by default; enable with `commands.restart=true`.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Dependencies: bump pi-\* stack to 0.42.2.
|
||||
@@ -4402,6 +4410,18 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Control UI: logs tab, streaming stability, focus mode, and large-output rendering fixes.
|
||||
- CLI/Gateway/Doctor: daemon/logs/status, auth migration, and diagnostics significantly expanded.
|
||||
|
||||
### Fixes
|
||||
|
||||
- **CLI/Gateway/Doctor:** daemon runtime selection + improved logs/status/health/errors; auth/password handling for local CLI; richer close/timeout details; auto-migrate legacy config/sessions/state; integrity checks + repair prompts; `--yes`/`--non-interactive`; `--deep` gateway scans; better restart/service hints.
|
||||
- **Agent loop + compaction:** compaction/pruning tuning, overflow handling, safer bootstrap context, and per-provider threading/confirmations; opt-in tool-result pruning + compact tracking.
|
||||
- **Sandbox + tools:** per-agent sandbox overrides, workspaceAccess controls, session tool visibility, tool policy overrides, process isolation, and tool schema/timeout/reaction unification.
|
||||
- **Providers (Telegram/WhatsApp/Discord/Slack/Signal/iMessage):** retry/backoff, threading, reactions, media groups/attachments, mention gating, typing behavior, and error/log stability; long polling + forum topic isolation for Telegram.
|
||||
- **Gateway/CLI UX:** `openclaw logs`, cron list colors/aliases, docs search, agents list/add/delete flows, status usage snapshots, runtime/auth source display, and `/status`/commands auth unification.
|
||||
- **Control UI/Web:** logs tab, focus mode polish, config form resilience, streaming stability, tool output caps, windowed chat history, and reconnect/password URL auth.
|
||||
- **macOS/Android/TUI/Build:** macOS gateway races, QR bundling, JSON5 config safety, Voice Wake hardening; Android EXIF rotation + APK naming/versioning; TUI key handling; tooling/bundling fixes.
|
||||
- **Packaging/compat:** npm dist folder coverage, Node 25 qrcode-terminal import fixes, Bun/Playwright/WebSocket patches, and Docker Bun install.
|
||||
- **Docs:** new FAQ/ClawHub/config examples/showcase entries and clarified auth, sandbox, and systemd docs.
|
||||
|
||||
### Breaking
|
||||
|
||||
- **SECURITY (update ASAP):** inbound DMs are now **locked down by default** on Telegram/WhatsApp/Signal/iMessage/Discord/Slack.
|
||||
@@ -4417,18 +4437,6 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
||||
- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides).
|
||||
- CLI: remove `update`, `gateway-daemon`, `gateway {install|uninstall|start|stop|restart|daemon status|wake|send|agent}`, and `telegram` commands; move `login/logout` to `providers login/logout` (top-level aliases hidden); use `daemon` for service control, `send`/`agent`/`wake` for RPC, and `nodes canvas` for canvas ops.
|
||||
|
||||
### Fixes
|
||||
|
||||
- **CLI/Gateway/Doctor:** daemon runtime selection + improved logs/status/health/errors; auth/password handling for local CLI; richer close/timeout details; auto-migrate legacy config/sessions/state; integrity checks + repair prompts; `--yes`/`--non-interactive`; `--deep` gateway scans; better restart/service hints.
|
||||
- **Agent loop + compaction:** compaction/pruning tuning, overflow handling, safer bootstrap context, and per-provider threading/confirmations; opt-in tool-result pruning + compact tracking.
|
||||
- **Sandbox + tools:** per-agent sandbox overrides, workspaceAccess controls, session tool visibility, tool policy overrides, process isolation, and tool schema/timeout/reaction unification.
|
||||
- **Providers (Telegram/WhatsApp/Discord/Slack/Signal/iMessage):** retry/backoff, threading, reactions, media groups/attachments, mention gating, typing behavior, and error/log stability; long polling + forum topic isolation for Telegram.
|
||||
- **Gateway/CLI UX:** `openclaw logs`, cron list colors/aliases, docs search, agents list/add/delete flows, status usage snapshots, runtime/auth source display, and `/status`/commands auth unification.
|
||||
- **Control UI/Web:** logs tab, focus mode polish, config form resilience, streaming stability, tool output caps, windowed chat history, and reconnect/password URL auth.
|
||||
- **macOS/Android/TUI/Build:** macOS gateway races, QR bundling, JSON5 config safety, Voice Wake hardening; Android EXIF rotation + APK naming/versioning; TUI key handling; tooling/bundling fixes.
|
||||
- **Packaging/compat:** npm dist folder coverage, Node 25 qrcode-terminal import fixes, Bun/Playwright/WebSocket patches, and Docker Bun install.
|
||||
- **Docs:** new FAQ/ClawHub/config examples/showcase entries and clarified auth, sandbox, and systemd docs.
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Skills additions (Himalaya email, CodexBar, 1Password).
|
||||
|
||||
@@ -23200,6 +23200,56 @@
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.mattermost.accounts.*.dmChannelRetry",
|
||||
"kind": "channel",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "channels.mattermost.accounts.*.dmChannelRetry.initialDelayMs",
|
||||
"kind": "channel",
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.mattermost.accounts.*.dmChannelRetry.maxDelayMs",
|
||||
"kind": "channel",
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.mattermost.accounts.*.dmChannelRetry.maxRetries",
|
||||
"kind": "channel",
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.mattermost.accounts.*.dmChannelRetry.timeoutMs",
|
||||
"kind": "channel",
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.mattermost.accounts.*.dmPolicy",
|
||||
"kind": "channel",
|
||||
@@ -23709,6 +23759,56 @@
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.mattermost.dmChannelRetry",
|
||||
"kind": "channel",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "channels.mattermost.dmChannelRetry.initialDelayMs",
|
||||
"kind": "channel",
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.mattermost.dmChannelRetry.maxDelayMs",
|
||||
"kind": "channel",
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.mattermost.dmChannelRetry.maxRetries",
|
||||
"kind": "channel",
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.mattermost.dmChannelRetry.timeoutMs",
|
||||
"kind": "channel",
|
||||
"type": "integer",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.mattermost.dmPolicy",
|
||||
"kind": "channel",
|
||||
@@ -37601,12 +37701,13 @@
|
||||
"path": "channels.zalouser.accounts.*.groupPolicy",
|
||||
"kind": "channel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"required": true,
|
||||
"enumValues": [
|
||||
"open",
|
||||
"disabled",
|
||||
"allowlist"
|
||||
],
|
||||
"defaultValue": "allowlist",
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
@@ -37903,12 +38004,13 @@
|
||||
"path": "channels.zalouser.groupPolicy",
|
||||
"kind": "channel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"required": true,
|
||||
"enumValues": [
|
||||
"open",
|
||||
"disabled",
|
||||
"allowlist"
|
||||
],
|
||||
"defaultValue": "allowlist",
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
@@ -39699,7 +39801,7 @@
|
||||
"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.",
|
||||
"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. Setting [\"*\"] means allow any browser origin and should be avoided outside tightly controlled local testing.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
@@ -41038,7 +41140,7 @@
|
||||
"access"
|
||||
],
|
||||
"label": "Hooks Allowed Agent IDs",
|
||||
"help": "Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents.",
|
||||
"help": "Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents and reduce blast radius if a hook token is exposed.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
@@ -42156,7 +42258,7 @@
|
||||
"security"
|
||||
],
|
||||
"label": "Hooks Auth Token",
|
||||
"help": "Shared bearer token checked by hooks ingress for request authentication before mappings run. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.",
|
||||
"help": "Shared bearer token checked by hooks ingress for request authentication before mappings run. Treat holders as full-trust callers for the hook ingress surface, not as a separate non-owner role. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
@@ -46269,6 +46371,127 @@
|
||||
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.chutes",
|
||||
"kind": "plugin",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "@openclaw/chutes-provider",
|
||||
"help": "OpenClaw Chutes.ai provider plugin (plugin: chutes)",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.chutes.config",
|
||||
"kind": "plugin",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "@openclaw/chutes-provider Config",
|
||||
"help": "Plugin-defined config payload for chutes.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.chutes.enabled",
|
||||
"kind": "plugin",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Enable @openclaw/chutes-provider",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.chutes.hooks",
|
||||
"kind": "plugin",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Plugin Hook Policy",
|
||||
"help": "Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.chutes.hooks.allowPromptInjection",
|
||||
"kind": "plugin",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"access"
|
||||
],
|
||||
"label": "Allow Prompt Injection Hooks",
|
||||
"help": "Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.chutes.subagent",
|
||||
"kind": "plugin",
|
||||
"type": "object",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"advanced"
|
||||
],
|
||||
"label": "Plugin Subagent Policy",
|
||||
"help": "Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.chutes.subagent.allowedModels",
|
||||
"kind": "plugin",
|
||||
"type": "array",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"access"
|
||||
],
|
||||
"label": "Plugin Subagent Allowed Models",
|
||||
"help": "Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.",
|
||||
"hasChildren": true
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.chutes.subagent.allowedModels.*",
|
||||
"kind": "plugin",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.chutes.subagent.allowModelOverride",
|
||||
"kind": "plugin",
|
||||
"type": "boolean",
|
||||
"required": false,
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [
|
||||
"access"
|
||||
],
|
||||
"label": "Allow Plugin Subagent Model Override",
|
||||
"help": "Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.",
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "plugins.entries.cloudflare-ai-gateway",
|
||||
"kind": "plugin",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5457}
|
||||
{"generatedBy":"scripts/generate-config-doc-baseline.ts","recordType":"meta","totalPaths":5476}
|
||||
{"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}
|
||||
@@ -2086,6 +2086,11 @@
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.commands.nativeSkills","kind":"channel","type":["boolean","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.dangerouslyAllowNameMatching","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.initialDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.maxRetries","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.dmChannelRetry.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.dmPolicy","kind":"channel","type":"string","required":true,"enumValues":["pairing","allowlist","open","disabled"],"defaultValue":"pairing","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.accounts.*.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@@ -2130,6 +2135,11 @@
|
||||
{"recordType":"path","path":"channels.mattermost.configWrites","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"Mattermost Config Writes","help":"Allow Mattermost to write config in response to channel events/commands (default: true).","hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.dangerouslyAllowNameMatching","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.defaultAccount","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.dmChannelRetry","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.mattermost.dmChannelRetry.initialDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.dmChannelRetry.maxDelayMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.dmChannelRetry.maxRetries","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.dmChannelRetry.timeoutMs","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.dmPolicy","kind":"channel","type":"string","required":true,"enumValues":["pairing","allowlist","open","disabled"],"defaultValue":"pairing","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.mattermost.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
@@ -3398,7 +3408,7 @@
|
||||
{"recordType":"path","path":"channels.zalouser.accounts.*.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalouser.accounts.*.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalouser.accounts.*.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalouser.accounts.*.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalouser.accounts.*.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalouser.accounts.*.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalouser.accounts.*.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalouser.accounts.*.groups.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -3426,7 +3436,7 @@
|
||||
{"recordType":"path","path":"channels.zalouser.enabled","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalouser.groupAllowFrom","kind":"channel","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalouser.groupAllowFrom.*","kind":"channel","type":["number","string"],"required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalouser.groupPolicy","kind":"channel","type":"string","required":false,"enumValues":["open","disabled","allowlist"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalouser.groupPolicy","kind":"channel","type":"string","required":true,"enumValues":["open","disabled","allowlist"],"defaultValue":"allowlist","deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.zalouser.groups","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalouser.groups.*","kind":"channel","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":true}
|
||||
{"recordType":"path","path":"channels.zalouser.groups.*.allow","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -3563,7 +3573,7 @@
|
||||
{"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":"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. Setting [\"*\"] means allow any browser origin and should be avoided outside tightly controlled local testing.","hasChildren":true}
|
||||
{"recordType":"path","path":"gateway.controlUi.allowedOrigins.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.controlUi.allowInsecureAuth","kind":"core","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access","advanced","network","security"],"label":"Insecure Control UI Auth Toggle","help":"Loosens strict browser auth checks for Control UI when you must run a non-standard setup. Keep this off unless you trust your network and proxy path, because impersonation risk is higher.","hasChildren":false}
|
||||
{"recordType":"path","path":"gateway.controlUi.basePath","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["network","storage"],"label":"Control UI Base Path","help":"Optional URL prefix where the Control UI is served (e.g. /openclaw).","hasChildren":false}
|
||||
@@ -3667,7 +3677,7 @@
|
||||
{"recordType":"path","path":"gateway.trustedProxies","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["network"],"label":"Gateway Trusted Proxy CIDRs","help":"CIDR/IP allowlist of upstream proxies permitted to provide forwarded client identity headers. Keep this list narrow so untrusted hops cannot impersonate users.","hasChildren":true}
|
||||
{"recordType":"path","path":"gateway.trustedProxies.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"hooks","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Hooks","help":"Inbound webhook automation surface for mapping external events into wake or agent actions in OpenClaw. Keep this locked down with explicit token/session/agent controls before exposing it beyond trusted networks.","hasChildren":true}
|
||||
{"recordType":"path","path":"hooks.allowedAgentIds","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Hooks Allowed Agent IDs","help":"Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents.","hasChildren":true}
|
||||
{"recordType":"path","path":"hooks.allowedAgentIds","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Hooks Allowed Agent IDs","help":"Allowlist of agent IDs that hook mappings are allowed to target when selecting execution agents. Use this to constrain automation events to dedicated service agents and reduce blast radius if a hook token is exposed.","hasChildren":true}
|
||||
{"recordType":"path","path":"hooks.allowedAgentIds.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"hooks.allowedSessionKeyPrefixes","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access","storage"],"label":"Hooks Allowed Session Key Prefixes","help":"Allowlist of accepted session-key prefixes for inbound hook requests when caller-provided keys are enabled. Use narrow prefixes to prevent arbitrary session-key injection.","hasChildren":true}
|
||||
{"recordType":"path","path":"hooks.allowedSessionKeyPrefixes.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
@@ -3754,7 +3764,7 @@
|
||||
{"recordType":"path","path":"hooks.path","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Hooks Endpoint Path","help":"HTTP path used by the hooks endpoint (for example `/hooks`) on the gateway control server. Use a non-guessable path and combine it with token validation for defense in depth.","hasChildren":false}
|
||||
{"recordType":"path","path":"hooks.presets","kind":"core","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Hooks Presets","help":"Named hook preset bundles applied at load time to seed standard mappings and behavior defaults. Keep preset usage explicit so operators can audit which automations are active.","hasChildren":true}
|
||||
{"recordType":"path","path":"hooks.presets.*","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"hooks.token","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Hooks Auth Token","help":"Shared bearer token checked by hooks ingress for request authentication before mappings run. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.","hasChildren":false}
|
||||
{"recordType":"path","path":"hooks.token","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":true,"tags":["auth","security"],"label":"Hooks Auth Token","help":"Shared bearer token checked by hooks ingress for request authentication before mappings run. Treat holders as full-trust callers for the hook ingress surface, not as a separate non-owner role. Use environment substitution and rotate regularly when webhook endpoints are internet-accessible.","hasChildren":false}
|
||||
{"recordType":"path","path":"hooks.transformsDir","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["storage"],"label":"Hooks Transforms Directory","help":"Base directory for hook transform modules referenced by mapping transform.module paths. Use a controlled repo directory so dynamic imports remain reviewable and predictable.","hasChildren":false}
|
||||
{"recordType":"path","path":"logging","kind":"core","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Logging","help":"Logging behavior controls for severity, output destinations, formatting, and sensitive-data redaction. Keep levels and redaction strict enough for production while preserving useful diagnostics.","hasChildren":true}
|
||||
{"recordType":"path","path":"logging.consoleLevel","kind":"core","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":["observability"],"label":"Console Log Level","help":"Console-specific log threshold: \"silent\", \"fatal\", \"error\", \"warn\", \"info\", \"debug\", or \"trace\" for terminal output control. Use this to keep local console quieter while retaining richer file logging if needed.","hasChildren":false}
|
||||
@@ -4093,6 +4103,15 @@
|
||||
{"recordType":"path","path":"plugins.entries.byteplus.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.byteplus.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.byteplus.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.chutes","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/chutes-provider","help":"OpenClaw Chutes.ai provider plugin (plugin: chutes)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.chutes.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/chutes-provider Config","help":"Plugin-defined config payload for chutes.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.chutes.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/chutes-provider","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.chutes.hooks","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Hook Policy","help":"Per-plugin typed hook policy controls for core-enforced safety gates. Use this to constrain high-impact hook categories without disabling the entire plugin.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.chutes.hooks.allowPromptInjection","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Prompt Injection Hooks","help":"Controls whether this plugin may mutate prompts through typed hooks. Set false to block `before_prompt_build` and ignore prompt-mutating fields from legacy `before_agent_start`, while preserving legacy `modelOverride` and `providerOverride` behavior.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.chutes.subagent","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Plugin Subagent Policy","help":"Per-plugin subagent runtime controls for model override trust and allowlists. Keep this unset unless a plugin must explicitly steer subagent model selection.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.chutes.subagent.allowedModels","kind":"plugin","type":"array","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Plugin Subagent Allowed Models","help":"Allowed override targets for trusted plugin subagent runs as canonical \"provider/model\" refs. Use \"*\" only when you intentionally allow any model.","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.chutes.subagent.allowedModels.*","kind":"plugin","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.chutes.subagent.allowModelOverride","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["access"],"label":"Allow Plugin Subagent Model Override","help":"Explicitly allows this plugin to request provider/model overrides in background subagent runs. Keep false unless the plugin is trusted to steer model selection.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider","help":"OpenClaw Cloudflare AI Gateway provider plugin (plugin: cloudflare-ai-gateway)","hasChildren":true}
|
||||
{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.config","kind":"plugin","type":"object","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"@openclaw/cloudflare-ai-gateway-provider Config","help":"Plugin-defined config payload for cloudflare-ai-gateway.","hasChildren":false}
|
||||
{"recordType":"path","path":"plugins.entries.cloudflare-ai-gateway.enabled","kind":"plugin","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["advanced"],"label":"Enable @openclaw/cloudflare-ai-gateway-provider","hasChildren":false}
|
||||
|
||||
@@ -8,7 +8,7 @@ title: "acp"
|
||||
|
||||
# acp
|
||||
|
||||
Run the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) bridge that talks to a OpenClaw Gateway.
|
||||
Run the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) bridge that talks to an OpenClaw Gateway.
|
||||
|
||||
This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway
|
||||
over WebSocket. It keeps ACP sessions mapped to Gateway session keys.
|
||||
@@ -102,7 +102,7 @@ Permission model (client debug mode):
|
||||
## How to use this
|
||||
|
||||
Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want
|
||||
it to drive a OpenClaw Gateway session.
|
||||
it to drive an OpenClaw Gateway session.
|
||||
|
||||
1. Ensure the Gateway is running (local or remote).
|
||||
2. Configure the Gateway target (config or flags).
|
||||
|
||||
@@ -276,9 +276,9 @@ Note: plugins can add additional top-level commands (for example `openclaw voice
|
||||
## Secrets
|
||||
|
||||
- `openclaw secrets reload` — re-resolve refs and atomically swap the runtime snapshot.
|
||||
- `openclaw secrets audit` — scan for plaintext residues, unresolved refs, and precedence drift.
|
||||
- `openclaw secrets configure` — interactive helper for provider setup + SecretRef mapping + preflight/apply.
|
||||
- `openclaw secrets apply --from <plan.json>` — apply a previously generated plan (`--dry-run` supported).
|
||||
- `openclaw secrets audit` — scan for plaintext residues, unresolved refs, and precedence drift (`--allow-exec` to execute exec providers during audit).
|
||||
- `openclaw secrets configure` — interactive helper for provider setup + SecretRef mapping + preflight/apply (`--allow-exec` to execute exec providers during preflight and exec-containing apply flows).
|
||||
- `openclaw secrets apply --from <plan.json>` — apply a previously generated plan (`--dry-run` supported; use `--allow-exec` to permit exec providers in dry-run and exec-containing write plans).
|
||||
|
||||
## Plugins
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ Related:
|
||||
|
||||
```bash
|
||||
openclaw plugins list
|
||||
openclaw plugins install <path-or-spec>
|
||||
openclaw plugins inspect <id>
|
||||
openclaw plugins enable <id>
|
||||
openclaw plugins disable <id>
|
||||
@@ -31,8 +32,6 @@ openclaw plugins update --all
|
||||
openclaw plugins marketplace list <marketplace>
|
||||
```
|
||||
|
||||
`info` is an alias for `inspect`.
|
||||
|
||||
Bundled plugins ship with OpenClaw but start disabled. Use `plugins enable` to
|
||||
activate them.
|
||||
|
||||
@@ -159,16 +158,18 @@ openclaw plugins inspect <id> --json
|
||||
```
|
||||
|
||||
Deep introspection for a single plugin. Shows identity, load status, source,
|
||||
plugin shape, registered capabilities, hooks, tools, commands, services,
|
||||
gateway methods, HTTP routes, policy flags, diagnostics, and install metadata.
|
||||
registered capabilities, hooks, tools, commands, services, gateway methods,
|
||||
HTTP routes, policy flags, diagnostics, and install metadata.
|
||||
|
||||
Plugin shape is derived from actual registration behavior:
|
||||
Each plugin is classified by what it actually registers at runtime:
|
||||
|
||||
- **plain-capability** — one capability type registered
|
||||
- **hybrid-capability** — multiple capability types registered
|
||||
- **plain-capability** — one capability type (e.g. a provider-only plugin)
|
||||
- **hybrid-capability** — multiple capability types (e.g. text + speech + images)
|
||||
- **hook-only** — only hooks, no capabilities or surfaces
|
||||
- **non-capability** — tools/commands/services but no capabilities
|
||||
|
||||
See [Plugins](/tools/plugin#plugin-shapes) for more on the capability model.
|
||||
|
||||
The `--json` flag outputs a machine-readable report suitable for scripting and
|
||||
auditing.
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ Use `openclaw secrets` to manage SecretRefs and keep the active runtime snapshot
|
||||
Command roles:
|
||||
|
||||
- `reload`: gateway RPC (`secrets.reload`) that re-resolves refs and swaps runtime snapshot only on full success (no config writes).
|
||||
- `audit`: read-only scan of configuration/auth/generated-model stores and legacy residues for plaintext, unresolved refs, and precedence drift.
|
||||
- `audit`: read-only scan of configuration/auth/generated-model stores and legacy residues for plaintext, unresolved refs, and precedence drift (exec refs are skipped unless `--allow-exec` is set).
|
||||
- `configure`: interactive planner for provider setup, target mapping, and preflight (TTY required).
|
||||
- `apply`: execute a saved plan (`--dry-run` for validation only), then scrub targeted plaintext residues.
|
||||
- `apply`: execute a saved plan (`--dry-run` for validation only; dry-run skips exec checks by default, and write mode rejects exec-containing plans unless `--allow-exec` is set), then scrub targeted plaintext residues.
|
||||
|
||||
Recommended operator loop:
|
||||
|
||||
@@ -29,6 +29,8 @@ openclaw secrets audit --check
|
||||
openclaw secrets reload
|
||||
```
|
||||
|
||||
If your plan includes `exec` SecretRefs/providers, pass `--allow-exec` on both dry-run and write apply commands.
|
||||
|
||||
Exit code note for CI/gates:
|
||||
|
||||
- `audit --check` returns `1` on findings.
|
||||
@@ -73,6 +75,7 @@ Header residue note:
|
||||
openclaw secrets audit
|
||||
openclaw secrets audit --check
|
||||
openclaw secrets audit --json
|
||||
openclaw secrets audit --allow-exec
|
||||
```
|
||||
|
||||
Exit behavior:
|
||||
@@ -83,6 +86,7 @@ Exit behavior:
|
||||
Report shape highlights:
|
||||
|
||||
- `status`: `clean | findings | unresolved`
|
||||
- `resolution`: `refsChecked`, `skippedExecRefs`, `resolvabilityComplete`
|
||||
- `summary`: `plaintextCount`, `unresolvedRefCount`, `shadowedRefCount`, `legacyResidueCount`
|
||||
- finding codes:
|
||||
- `PLAINTEXT_FOUND`
|
||||
@@ -115,6 +119,7 @@ Flags:
|
||||
- `--providers-only`: configure `secrets.providers` only, skip credential mapping.
|
||||
- `--skip-provider-setup`: skip provider setup and map credentials to existing providers.
|
||||
- `--agent <id>`: scope `auth-profiles.json` target discovery and writes to one agent store.
|
||||
- `--allow-exec`: allow exec SecretRef checks during preflight/apply (may execute provider commands).
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -124,6 +129,7 @@ Notes:
|
||||
- `configure` supports creating new `auth-profiles.json` mappings directly in the picker flow.
|
||||
- Canonical supported surface: [SecretRef Credential Surface](/reference/secretref-credential-surface).
|
||||
- It performs preflight resolution before apply.
|
||||
- If preflight/apply includes exec refs, keep `--allow-exec` set for both steps.
|
||||
- Generated plans default to scrub options (`scrubEnv`, `scrubAuthProfilesForProviderTargets`, `scrubLegacyAuthJson` all enabled).
|
||||
- Apply path is one-way for scrubbed plaintext values.
|
||||
- Without `--apply`, CLI still prompts `Apply this plan now?` after preflight.
|
||||
@@ -141,10 +147,19 @@ Apply or preflight a plan generated previously:
|
||||
|
||||
```bash
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --allow-exec
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run --allow-exec
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --json
|
||||
```
|
||||
|
||||
Exec behavior:
|
||||
|
||||
- `--dry-run` validates preflight without writing files.
|
||||
- exec SecretRef checks are skipped by default in dry-run.
|
||||
- write mode rejects plans that contain exec SecretRefs/providers unless `--allow-exec` is set.
|
||||
- Use `--allow-exec` to opt in to exec provider checks/execution in either mode.
|
||||
|
||||
Plan contract details (allowed target paths, validation rules, and failure semantics):
|
||||
|
||||
- [Secrets Apply Plan Contract](/gateway/secrets-plan-contract)
|
||||
|
||||
@@ -1055,6 +1055,7 @@
|
||||
"plugins/zalouser",
|
||||
"plugins/manifest",
|
||||
"plugins/agent-tools",
|
||||
"tools/capability-cookbook",
|
||||
"prose"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -81,6 +81,12 @@ Invalid plan target path for models.providers.apiKey: models.providers.openai.ba
|
||||
|
||||
No writes are committed for an invalid plan.
|
||||
|
||||
## Exec provider consent behavior
|
||||
|
||||
- `--dry-run` skips exec SecretRef checks by default.
|
||||
- Plans containing exec SecretRefs/providers are rejected in write mode unless `--allow-exec` is set.
|
||||
- When validating/applying exec-containing plans, pass `--allow-exec` in both dry-run and write commands.
|
||||
|
||||
## Runtime and audit scope notes
|
||||
|
||||
- Ref-only `auth-profiles.json` entries (`keyRef`/`tokenRef`) are included in runtime resolution and audit coverage.
|
||||
@@ -94,6 +100,10 @@ openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
|
||||
|
||||
# Then apply for real
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
|
||||
|
||||
# For exec-containing plans, opt in explicitly in both modes
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run --allow-exec
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --allow-exec
|
||||
```
|
||||
|
||||
If apply fails with an invalid target path message, regenerate the plan with `openclaw secrets configure` or fix the target path to a supported shape above.
|
||||
|
||||
@@ -414,6 +414,11 @@ Findings include:
|
||||
- precedence shadowing (`auth-profiles.json` taking priority over `openclaw.json` refs)
|
||||
- legacy residues (`auth.json`, OAuth reminders)
|
||||
|
||||
Exec note:
|
||||
|
||||
- By default, audit skips exec SecretRef resolvability checks to avoid command side effects.
|
||||
- Use `openclaw secrets audit --allow-exec` to execute exec providers during audit.
|
||||
|
||||
Header residue note:
|
||||
|
||||
- Sensitive provider header detection is name-heuristic based (common auth/credential header names and fragments such as `authorization`, `x-api-key`, `token`, `secret`, `password`, and `credential`).
|
||||
@@ -429,6 +434,11 @@ Interactive helper that:
|
||||
- runs preflight resolution
|
||||
- can apply immediately
|
||||
|
||||
Exec note:
|
||||
|
||||
- Preflight skips exec SecretRef checks unless `--allow-exec` is set.
|
||||
- If you apply directly from `configure --apply` and the plan includes exec refs/providers, keep `--allow-exec` set for the apply step too.
|
||||
|
||||
Helpful modes:
|
||||
|
||||
- `openclaw secrets configure --providers-only`
|
||||
@@ -447,9 +457,16 @@ Apply a saved plan:
|
||||
|
||||
```bash
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --allow-exec
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run
|
||||
openclaw secrets apply --from /tmp/openclaw-secrets-plan.json --dry-run --allow-exec
|
||||
```
|
||||
|
||||
Exec note:
|
||||
|
||||
- dry-run skips exec checks unless `--allow-exec` is set.
|
||||
- write mode rejects plans containing exec SecretRefs/providers unless `--allow-exec` is set.
|
||||
|
||||
For strict target/path contract details and exact rejection rules, see:
|
||||
|
||||
- [Secrets Apply Plan Contract](/gateway/secrets-plan-contract)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "Move (migrate) a OpenClaw install from one machine to another"
|
||||
summary: "Move (migrate) an OpenClaw install from one machine to another"
|
||||
read_when:
|
||||
- You are moving OpenClaw to a new laptop/server
|
||||
- You want to preserve sessions, auth, and channel logins (WhatsApp, etc.)
|
||||
@@ -8,7 +8,7 @@ title: "Migration Guide"
|
||||
|
||||
# Migrating OpenClaw to a new machine
|
||||
|
||||
This guide migrates a OpenClaw Gateway from one machine to another **without redoing onboarding**.
|
||||
This guide migrates an OpenClaw Gateway from one machine to another **without redoing onboarding**.
|
||||
|
||||
The migration is simple conceptually:
|
||||
|
||||
|
||||
13
docs/pi.md
13
docs/pi.md
@@ -119,19 +119,24 @@ src/agents/
|
||||
│ ├── browser-tool.ts
|
||||
│ ├── canvas-tool.ts
|
||||
│ ├── cron-tool.ts
|
||||
│ ├── discord-actions*.ts
|
||||
│ ├── gateway-tool.ts
|
||||
│ ├── image-tool.ts
|
||||
│ ├── message-tool.ts
|
||||
│ ├── nodes-tool.ts
|
||||
│ ├── session*.ts
|
||||
│ ├── slack-actions.ts
|
||||
│ ├── telegram-actions.ts
|
||||
│ ├── web-*.ts
|
||||
│ └── whatsapp-actions.ts
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
|
||||
Channel-specific message action runtimes now live in the plugin-owned extension
|
||||
directories instead of under `src/agents/tools`, for example:
|
||||
|
||||
- `extensions/discord/src/actions/runtime*.ts`
|
||||
- `extensions/slack/src/action-runtime.ts`
|
||||
- `extensions/telegram/src/action-runtime.ts`
|
||||
- `extensions/whatsapp/src/action-runtime.ts`
|
||||
|
||||
## Core Integration Flow
|
||||
|
||||
### 1. Running an Embedded Agent
|
||||
|
||||
@@ -7,7 +7,7 @@ title: "Remote Control"
|
||||
|
||||
# Remote OpenClaw (macOS ⇄ remote host)
|
||||
|
||||
This flow lets the macOS app act as a full remote control for a OpenClaw gateway running on another host (desktop/server). It’s the app’s **Remote over SSH** (remote run) feature. All features—health checks, Voice Wake forwarding, and Web Chat—reuse the same remote SSH configuration from _Settings → General_.
|
||||
This flow lets the macOS app act as a full remote control for an OpenClaw gateway running on another host (desktop/server). It’s the app’s **Remote over SSH** (remote run) feature. All features—health checks, Voice Wake forwarding, and Web Chat—reuse the same remote SSH configuration from _Settings → General_.
|
||||
|
||||
## Modes
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
summary: "Plugin manifest + JSON schema requirements (strict config validation)"
|
||||
read_when:
|
||||
- You are building a OpenClaw plugin
|
||||
- You are building an OpenClaw plugin
|
||||
- You need to ship a plugin config schema or debug plugin validation errors
|
||||
title: "Plugin Manifest"
|
||||
---
|
||||
@@ -32,7 +32,8 @@ Every native OpenClaw plugin **must** ship a `openclaw.plugin.json` file in the
|
||||
plugin errors and block config validation.
|
||||
|
||||
See the full plugin system guide: [Plugins](/tools/plugin).
|
||||
For the public capability model: [Capability model](/tools/plugin#public-capability-model).
|
||||
For the native capability model and current external-compatibility guidance:
|
||||
[Capability model](/tools/plugin#public-capability-model).
|
||||
|
||||
## Required fields
|
||||
|
||||
@@ -120,6 +121,8 @@ Example:
|
||||
- If plugin config exists but the plugin is **disabled**, the config is kept and
|
||||
a **warning** is surfaced in Doctor + logs.
|
||||
|
||||
See [Configuration reference](/configuration) for the full `plugins.*` schema.
|
||||
|
||||
## Notes
|
||||
|
||||
- The manifest is **required for native OpenClaw plugins**, including local filesystem loads.
|
||||
@@ -130,7 +133,9 @@ Example:
|
||||
runtime just to inspect env names.
|
||||
- `providerAuthChoices` is the cheap metadata path for auth-choice pickers,
|
||||
`--auth-choice` resolution, preferred-provider mapping, and simple onboarding
|
||||
CLI flag registration before provider runtime loads.
|
||||
CLI flag registration before provider runtime loads. For runtime wizard
|
||||
metadata that requires provider code, see
|
||||
[Provider runtime hooks](/tools/plugin#provider-runtime-hooks).
|
||||
- Exclusive plugin kinds are selected through `plugins.slots.*`.
|
||||
- `kind: "memory"` is selected by `plugins.slots.memory`.
|
||||
- `kind: "context-engine"` is selected by `plugins.slots.contextEngine`
|
||||
|
||||
@@ -47,6 +47,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
|
||||
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
|
||||
- [Venice (Venice AI, privacy-focused)](/providers/venice)
|
||||
- [vLLM (local models)](/providers/vllm)
|
||||
- [xAI](/providers/xai)
|
||||
- [Xiaomi](/providers/xiaomi)
|
||||
- [Z.AI](/providers/zai)
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ model as `provider/model`.
|
||||
- [Venice (Venice AI)](/providers/venice)
|
||||
- [Amazon Bedrock](/providers/bedrock)
|
||||
- [Qianfan](/providers/qianfan)
|
||||
- [xAI](/providers/xai)
|
||||
|
||||
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
|
||||
see [Model providers](/concepts/model-providers).
|
||||
|
||||
61
docs/providers/xai.md
Normal file
61
docs/providers/xai.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
summary: "Use xAI Grok models in OpenClaw"
|
||||
read_when:
|
||||
- You want to use Grok models in OpenClaw
|
||||
- You are configuring xAI auth or model ids
|
||||
title: "xAI"
|
||||
---
|
||||
|
||||
# xAI
|
||||
|
||||
OpenClaw ships a bundled `xai` provider plugin for Grok models.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Create an API key in the xAI console.
|
||||
2. Set `XAI_API_KEY`, or run:
|
||||
|
||||
```bash
|
||||
openclaw onboard --auth-choice xai-api-key
|
||||
```
|
||||
|
||||
3. Pick a model such as:
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: { defaults: { model: { primary: "xai/grok-4" } } },
|
||||
}
|
||||
```
|
||||
|
||||
## Current bundled model catalog
|
||||
|
||||
OpenClaw now includes these xAI model families out of the box:
|
||||
|
||||
- `grok-4`, `grok-4-0709`
|
||||
- `grok-4-fast-reasoning`, `grok-4-fast-non-reasoning`
|
||||
- `grok-4-1-fast-reasoning`, `grok-4-1-fast-non-reasoning`
|
||||
- `grok-4.20-experimental-beta-0304-reasoning`
|
||||
- `grok-4.20-experimental-beta-0304-non-reasoning`
|
||||
- `grok-code-fast-1`
|
||||
|
||||
The plugin also forward-resolves newer `grok-4*` and `grok-code-fast*` ids when
|
||||
they follow the same API shape.
|
||||
|
||||
## Web search
|
||||
|
||||
The bundled `grok` web-search provider uses `XAI_API_KEY` too:
|
||||
|
||||
```bash
|
||||
openclaw config set tools.web.search.provider grok
|
||||
```
|
||||
|
||||
## Known limits
|
||||
|
||||
- Auth is API-key only today. There is no xAI OAuth/device-code flow in OpenClaw yet.
|
||||
- `grok-4.20-multi-agent-experimental-beta-0304` is not supported on the normal xAI provider path because it requires a different upstream API surface than the standard OpenClaw xAI transport.
|
||||
- Native xAI server-side tools such as `x_search` and `code_execution` are not yet first-class model-provider features in the bundled plugin.
|
||||
|
||||
## Notes
|
||||
|
||||
- OpenClaw applies xAI-specific tool-schema and tool-call compatibility fixes automatically on the shared runner path.
|
||||
- For the broader provider overview, see [Model providers](/providers/index).
|
||||
@@ -231,7 +231,7 @@ Triggered by a roof camera: ask OpenClaw to snap a sky photo whenever it looks p
|
||||
<Card title="Visual Morning Briefing Scene" icon="robot" href="https://x.com/buddyhadry/status/2010005331925954739">
|
||||
**@buddyhadry** • `automation` `briefing` `images` `telegram`
|
||||
|
||||
A scheduled prompt generates a single "scene" image each morning (weather, tasks, date, favorite post/quote) via a OpenClaw persona.
|
||||
A scheduled prompt generates a single "scene" image each morning (weather, tasks, date, favorite post/quote) via an OpenClaw persona.
|
||||
</Card>
|
||||
|
||||
<Card title="Padel Court Booking" icon="calendar-check" href="https://github.com/joshp123/padel-cli">
|
||||
|
||||
@@ -191,7 +191,7 @@ Notes:
|
||||
## Browserless (hosted remote CDP)
|
||||
|
||||
[Browserless](https://browserless.io) is a hosted Chromium service that exposes
|
||||
CDP endpoints over HTTPS. You can point a OpenClaw browser profile at a
|
||||
CDP endpoints over HTTPS. You can point an OpenClaw browser profile at a
|
||||
Browserless region endpoint and authenticate with your API key.
|
||||
|
||||
Example:
|
||||
|
||||
@@ -66,7 +66,7 @@ pnpm add -g clawhub
|
||||
|
||||
## How it fits into OpenClaw
|
||||
|
||||
By default, the CLI installs skills into `./skills` under your current working directory. If a OpenClaw workspace is configured, `clawhub` falls back to that workspace unless you override `--workdir` (or `CLAWHUB_WORKDIR`). OpenClaw loads workspace skills from `<workspace>/skills` and will pick them up in the **next** session. If you already use `~/.openclaw/skills` or bundled skills, workspace skills take precedence.
|
||||
By default, the CLI installs skills into `./skills` under your current working directory. If an OpenClaw workspace is configured, `clawhub` falls back to that workspace unless you override `--workdir` (or `CLAWHUB_WORKDIR`). OpenClaw loads workspace skills from `<workspace>/skills` and will pick them up in the **next** session. If you already use `~/.openclaw/skills` or bundled skills, workspace skills take precedence.
|
||||
|
||||
For more detail on how skills are loaded, shared, and gated, see
|
||||
[Skills](/tools/skills).
|
||||
|
||||
@@ -37,12 +37,8 @@ openclaw plugins list
|
||||
openclaw plugins install @openclaw/voice-call
|
||||
```
|
||||
|
||||
Npm specs are **registry-only** (package name + optional **exact version** or
|
||||
**dist-tag**). Git/URL/file specs and semver ranges are rejected.
|
||||
|
||||
Bare specs and `@latest` stay on the stable track. If npm resolves either of
|
||||
those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a
|
||||
prerelease tag such as `@beta`/`@rc` or an exact prerelease version.
|
||||
Npm specs are registry-only. See [install rules](/cli/plugins#install) for
|
||||
details on pinning, prerelease gating, and supported spec formats.
|
||||
|
||||
3. Restart the Gateway, then configure under `plugins.entries.<id>.config`.
|
||||
|
||||
@@ -107,8 +103,8 @@ conversation, and it runs after core approval handling finishes.
|
||||
|
||||
## Public capability model
|
||||
|
||||
Capabilities are the public plugin model. Every native OpenClaw plugin
|
||||
registers against one or more capability types:
|
||||
Capabilities are the public **native plugin** model inside OpenClaw. Every
|
||||
native OpenClaw plugin registers against one or more capability types:
|
||||
|
||||
| Capability | Registration method | Example plugins |
|
||||
| ------------------- | --------------------------------------------- | ------------------------- |
|
||||
@@ -120,7 +116,31 @@ registers against one or more capability types:
|
||||
| Channel / messaging | `api.registerChannel(...)` | `msteams`, `matrix` |
|
||||
|
||||
A plugin that registers zero capabilities but provides hooks, tools, or
|
||||
services is a **legacy hook-only** plugin. That shape is still fully supported.
|
||||
services is a **legacy hook-only** plugin. That pattern is still fully supported.
|
||||
|
||||
### External compatibility stance
|
||||
|
||||
The capability model is landed in core and used by bundled/native plugins
|
||||
today, but external plugin compatibility still needs a tighter bar than "it is
|
||||
exported, therefore it is frozen."
|
||||
|
||||
Current guidance:
|
||||
|
||||
- **existing external plugins:** keep hook-based integrations working; treat
|
||||
this as the compatibility baseline
|
||||
- **new bundled/native plugins:** prefer explicit capability registration over
|
||||
vendor-specific reach-ins or new hook-only designs
|
||||
- **external plugins adopting capability registration:** allowed, but treat the
|
||||
capability-specific helper surfaces as evolving unless docs explicitly mark a
|
||||
contract as stable
|
||||
|
||||
Practical rule:
|
||||
|
||||
- capability registration APIs are the intended direction
|
||||
- legacy hooks remain the safest no-breakage path for external plugins during
|
||||
the transition
|
||||
- exported helper subpaths are not all equal; prefer the narrow documented
|
||||
contract, not incidental helper exports
|
||||
|
||||
### Plugin shapes
|
||||
|
||||
@@ -140,13 +160,6 @@ registration behavior (not just static metadata):
|
||||
Use `openclaw plugins inspect <id>` to see a plugin's shape and capability
|
||||
breakdown. See [CLI reference](/cli/plugins#inspect) for details.
|
||||
|
||||
### Capability labels
|
||||
|
||||
Plugin capabilities use two stability labels:
|
||||
|
||||
- `public` — stable, documented, and safe to depend on
|
||||
- `experimental` — may change between releases
|
||||
|
||||
### Legacy hooks
|
||||
|
||||
The `before_agent_start` hook remains supported as a compatibility path for
|
||||
@@ -202,7 +215,7 @@ The current boundary is:
|
||||
channel-specific schema fragments
|
||||
- channel plugins execute the final action through their action adapter
|
||||
|
||||
For channel plugins, the preferred SDK surface is
|
||||
For channel plugins, the SDK surface is
|
||||
`ChannelMessageActionAdapter.describeMessageTool(...)`. That unified discovery
|
||||
call lets a plugin return its visible actions, capabilities, and schema
|
||||
contributions together so those pieces do not drift apart.
|
||||
@@ -228,6 +241,26 @@ responsible for forwarding the current chat/session identity into the plugin
|
||||
discovery boundary so the shared `message` tool exposes the right channel-owned
|
||||
surface for the current turn.
|
||||
|
||||
For channel-owned execution helpers, bundled plugins should keep the execution
|
||||
runtime inside their own extension modules. Core no longer owns the Discord,
|
||||
Slack, Telegram, or WhatsApp message-action runtimes under `src/agents/tools`.
|
||||
We do not publish separate `plugin-sdk/*-action-runtime` subpaths, and bundled
|
||||
plugins should import their own local runtime code directly from their
|
||||
extension-owned modules.
|
||||
|
||||
For polls specifically, there are two execution paths:
|
||||
|
||||
- `outbound.sendPoll` is the shared baseline for channels that fit the common
|
||||
poll model
|
||||
- `actions.handleAction("poll")` is the preferred path for channel-specific
|
||||
poll semantics or extra poll parameters
|
||||
|
||||
Core now defers shared poll parsing until after plugin poll dispatch declines
|
||||
the action, so plugin-owned poll handlers can accept channel-specific poll
|
||||
fields without being blocked by the generic poll parser first.
|
||||
|
||||
See [Load pipeline](#load-pipeline) for the full startup sequence.
|
||||
|
||||
## Capability ownership model
|
||||
|
||||
OpenClaw treats a native plugin as the ownership boundary for a **company** or a
|
||||
@@ -407,7 +440,7 @@ native OpenClaw plugin sources. OpenClaw resolves the marketplace entry first,
|
||||
then runs the normal install path for the resolved source.
|
||||
|
||||
They are shown in the plugin list as `format=bundle`, with a subtype of
|
||||
`codex` or `claude` in verbose/info output.
|
||||
`codex`, `claude`, or `cursor` in verbose/inspect output.
|
||||
|
||||
See [Plugin bundles](/plugins/bundles) for the exact detection rules, mapping
|
||||
behavior, and current support matrix.
|
||||
@@ -537,7 +570,8 @@ Native OpenClaw plugins can register capabilities and surfaces:
|
||||
- **Skills** (by listing `skills` directories in the plugin manifest)
|
||||
- **Auto-reply commands** (execute without invoking the AI agent)
|
||||
|
||||
Native OpenClaw plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||
Native OpenClaw plugins run in-process with the Gateway (see
|
||||
[Execution model](#execution-model) for trust implications).
|
||||
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
||||
|
||||
Think of these registrations as **capability claims**. A plugin is not supposed
|
||||
@@ -662,112 +696,35 @@ one-flag auth wiring without loading provider runtime. Keep provider runtime
|
||||
`envVars` for operator-facing hints such as onboarding labels or OAuth
|
||||
client-id/client-secret setup vars.
|
||||
|
||||
### Hook order
|
||||
### Hook order and usage
|
||||
|
||||
For model/provider plugins, OpenClaw uses hooks in this rough order:
|
||||
For model/provider plugins, OpenClaw calls hooks in this rough order.
|
||||
The "When to use" column is the quick decision guide.
|
||||
|
||||
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. `formatApiKey`
|
||||
Provider-owned auth-profile formatter used when a stored auth profile needs
|
||||
to become the runtime `apiKey` string.
|
||||
10. `refreshOAuth`
|
||||
Provider-owned OAuth refresh override for custom refresh endpoints or
|
||||
refresh-failure policy.
|
||||
11. `buildAuthDoctorHint`
|
||||
Provider-owned repair hint appended when OAuth refresh fails.
|
||||
12. `isCacheTtlEligible`
|
||||
Provider-owned prompt-cache policy for proxy/backhaul providers.
|
||||
13. `buildMissingAuthMessage`
|
||||
Provider-owned replacement for the generic missing-auth recovery message.
|
||||
14. `suppressBuiltInModel`
|
||||
Provider-owned stale upstream model suppression plus optional user-facing
|
||||
error hint.
|
||||
15. `augmentModelCatalog`
|
||||
Provider-owned synthetic/final catalog rows appended after discovery.
|
||||
16. `isBinaryThinking`
|
||||
Provider-owned on/off reasoning toggle for binary-thinking providers.
|
||||
17. `supportsXHighThinking`
|
||||
Provider-owned `xhigh` reasoning support for selected models.
|
||||
18. `resolveDefaultThinkingLevel`
|
||||
Provider-owned default `/think` level for a specific model family.
|
||||
19. `isModernModelRef`
|
||||
Provider-owned modern-model matcher used by live profile filters and smoke
|
||||
selection.
|
||||
20. `prepareRuntimeAuth`
|
||||
Exchanges a configured credential into the actual runtime token/key just
|
||||
before inference.
|
||||
21. `resolveUsageAuth`
|
||||
Resolves usage/billing credentials for `/usage` and related status
|
||||
surfaces.
|
||||
22. `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
|
||||
- `formatApiKey`: turn a stored auth profile into the runtime `apiKey` string without hardcoding provider token blobs in core
|
||||
- `refreshOAuth`: own OAuth refresh for providers that do not fit the shared `pi-ai` refreshers
|
||||
- `buildAuthDoctorHint`: append provider-owned auth repair guidance when refresh fails
|
||||
- `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
|
||||
- `isBinaryThinking`: expose binary on/off reasoning UX without hardcoding provider ids in `/think`
|
||||
- `supportsXHighThinking`: opt specific models into the `xhigh` reasoning level
|
||||
- `resolveDefaultThinkingLevel`: keep provider/model default reasoning policy out of core
|
||||
- `isModernModelRef`: keep live/smoke model family inclusion rules with the provider
|
||||
- `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 stores extra metadata in auth profiles and needs a custom runtime token shape: use `formatApiKey`
|
||||
- provider needs a custom OAuth refresh endpoint or refresh failure policy: use `refreshOAuth`
|
||||
- provider needs provider-owned auth repair guidance after refresh failure: use `buildAuthDoctorHint`
|
||||
- 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 exposes only binary thinking on/off: use `isBinaryThinking`
|
||||
- provider wants `xhigh` on only a subset of models: use `supportsXHighThinking`
|
||||
- provider owns default `/think` policy for a model family: use `resolveDefaultThinkingLevel`
|
||||
- provider owns live/smoke preferred-model matching: use `isModernModelRef`
|
||||
- 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`
|
||||
| # | Hook | What it does | When to use |
|
||||
| --- | ----------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| 1 | `catalog` | Publish provider config into `models.providers` during `models.json` generation | Provider owns a catalog or base URL defaults |
|
||||
| — | _(built-in model lookup)_ | OpenClaw tries the normal registry/catalog path first | _(not a plugin hook)_ |
|
||||
| 2 | `resolveDynamicModel` | Sync fallback for provider-owned model ids not in the local registry yet | Provider accepts arbitrary upstream model ids |
|
||||
| 3 | `prepareDynamicModel` | Async warm-up, then `resolveDynamicModel` runs again | Provider needs network metadata before resolving unknown ids |
|
||||
| 4 | `normalizeResolvedModel` | Final rewrite before the embedded runner uses the resolved model | Provider needs transport rewrites but still uses a core transport |
|
||||
| 5 | `capabilities` | Provider-owned transcript/tooling metadata used by shared core logic | Provider needs transcript/provider-family quirks |
|
||||
| 6 | `prepareExtraParams` | Request-param normalization before generic stream option wrappers | Provider needs default request params or per-provider param cleanup |
|
||||
| 7 | `wrapStreamFn` | Stream wrapper after generic wrappers are applied | Provider needs request headers/body/model compat wrappers without a custom transport |
|
||||
| 8 | `formatApiKey` | Auth-profile formatter: stored profile becomes the runtime `apiKey` string | Provider stores extra auth metadata and needs a custom runtime token shape |
|
||||
| 9 | `refreshOAuth` | OAuth refresh override for custom refresh endpoints or refresh-failure policy | Provider does not fit the shared `pi-ai` refreshers |
|
||||
| 10 | `buildAuthDoctorHint` | Repair hint appended when OAuth refresh fails | Provider needs provider-owned auth repair guidance after refresh failure |
|
||||
| 11 | `isCacheTtlEligible` | Prompt-cache policy for proxy/backhaul providers | Provider needs proxy-specific cache TTL gating |
|
||||
| 12 | `buildMissingAuthMessage` | Replacement for the generic missing-auth recovery message | Provider needs a provider-specific missing-auth recovery hint |
|
||||
| 13 | `suppressBuiltInModel` | Stale upstream model suppression plus optional user-facing error hint | Provider needs to hide stale upstream rows or replace them with a vendor hint |
|
||||
| 14 | `augmentModelCatalog` | Synthetic/final catalog rows appended after discovery | Provider needs synthetic forward-compat rows in `models list` and pickers |
|
||||
| 15 | `isBinaryThinking` | On/off reasoning toggle for binary-thinking providers | Provider exposes only binary thinking on/off |
|
||||
| 16 | `supportsXHighThinking` | `xhigh` reasoning support for selected models | Provider wants `xhigh` on only a subset of models |
|
||||
| 17 | `resolveDefaultThinkingLevel` | Default `/think` level for a specific model family | Provider owns default `/think` policy for a model family |
|
||||
| 18 | `isModernModelRef` | Modern-model matcher for live profile filters and smoke selection | Provider owns live/smoke preferred-model matching |
|
||||
| 19 | `prepareRuntimeAuth` | Exchange a configured credential into the actual runtime token/key just before inference | Provider needs a token exchange or short-lived request credential |
|
||||
| 20 | `resolveUsageAuth` | Resolve usage/billing credentials for `/usage` and related status surfaces | Provider needs custom usage/quota token parsing or a different usage credential |
|
||||
| 21 | `fetchUsageSnapshot` | Fetch and normalize provider-specific usage/quota snapshots after auth is resolved | Provider needs a provider-specific usage endpoint or payload parser |
|
||||
|
||||
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
|
||||
@@ -1126,14 +1083,24 @@ Use SDK subpaths instead of the monolithic `openclaw/plugin-sdk` import when
|
||||
authoring plugins:
|
||||
|
||||
- `openclaw/plugin-sdk/core` for the smallest generic plugin-facing contract.
|
||||
It also carries small assembly helpers such as
|
||||
`definePluginEntry`, `defineChannelPluginEntry`, `defineSetupPluginEntry`,
|
||||
and `createChannelPluginBase` for bundled or third-party plugin entry wiring.
|
||||
- Domain subpaths such as `openclaw/plugin-sdk/channel-config-helpers`,
|
||||
`openclaw/plugin-sdk/channel-config-schema`,
|
||||
`openclaw/plugin-sdk/channel-policy`,
|
||||
`openclaw/plugin-sdk/channel-runtime`,
|
||||
`openclaw/plugin-sdk/config-runtime`,
|
||||
`openclaw/plugin-sdk/agent-runtime`,
|
||||
`openclaw/plugin-sdk/lazy-runtime`,
|
||||
`openclaw/plugin-sdk/reply-history`,
|
||||
`openclaw/plugin-sdk/routing`,
|
||||
`openclaw/plugin-sdk/runtime-store`, and
|
||||
`openclaw/plugin-sdk/directory-runtime` for shared runtime/config helpers.
|
||||
- Narrow channel-core subpaths such as `openclaw/plugin-sdk/discord-core`,
|
||||
`openclaw/plugin-sdk/telegram-core`, `openclaw/plugin-sdk/whatsapp-core`,
|
||||
and `openclaw/plugin-sdk/line-core` for channel-specific primitives that
|
||||
should stay smaller than the full channel helper barrels.
|
||||
- `openclaw/plugin-sdk/compat` remains as a legacy migration surface for older
|
||||
external plugins. Bundled plugins should not use it, and non-test imports emit
|
||||
a one-time deprecation warning outside test environments.
|
||||
@@ -1173,6 +1140,54 @@ authoring plugins:
|
||||
`openclaw/plugin-sdk/twitch`, `openclaw/plugin-sdk/voice-call`,
|
||||
`openclaw/plugin-sdk/zalo`, and `openclaw/plugin-sdk/zalouser`.
|
||||
|
||||
## Channel target resolution
|
||||
|
||||
Channel plugins should own channel-specific target semantics. Keep the shared
|
||||
outbound host generic and use the messaging adapter surface for provider rules:
|
||||
|
||||
- `messaging.inferTargetChatType({ to })` decides whether a normalized target
|
||||
should be treated as `direct`, `group`, or `channel` before directory lookup.
|
||||
- `messaging.targetResolver.looksLikeId(raw, normalized)` tells core whether an
|
||||
input should skip straight to id-like resolution instead of directory search.
|
||||
- `messaging.targetResolver.resolveTarget(...)` is the plugin fallback when
|
||||
core needs a final provider-owned resolution after normalization or after a
|
||||
directory miss.
|
||||
- `messaging.resolveOutboundSessionRoute(...)` owns provider-specific session
|
||||
route construction once a target is resolved.
|
||||
|
||||
Recommended split:
|
||||
|
||||
- Use `inferTargetChatType` for category decisions that should happen before
|
||||
searching peers/groups.
|
||||
- Use `looksLikeId` for “treat this as an explicit/native target id” checks.
|
||||
- Use `resolveTarget` for provider-specific normalization fallback, not for
|
||||
broad directory search.
|
||||
- Keep provider-native ids like chat ids, thread ids, JIDs, handles, and room
|
||||
ids inside `target` values or provider-specific params, not in generic SDK
|
||||
fields.
|
||||
|
||||
## Config-backed directories
|
||||
|
||||
Plugins that derive directory entries from config should keep that logic in the
|
||||
plugin and reuse the shared helpers from
|
||||
`openclaw/plugin-sdk/directory-runtime`.
|
||||
|
||||
Use this when a channel needs config-backed peers/groups such as:
|
||||
|
||||
- allowlist-driven DM peers
|
||||
- configured channel/group maps
|
||||
- account-scoped static directory fallbacks
|
||||
|
||||
The shared helpers in `directory-runtime` only handle generic operations:
|
||||
|
||||
- query filtering
|
||||
- limit application
|
||||
- deduping/normalization helpers
|
||||
- building `ChannelDirectoryEntry[]`
|
||||
|
||||
Channel-specific account inspection and id normalization should stay in the
|
||||
plugin implementation.
|
||||
|
||||
## Provider catalogs
|
||||
|
||||
Provider plugins can define model catalogs for inference with
|
||||
@@ -1209,6 +1224,10 @@ Compatibility note:
|
||||
- New and migrated bundled plugins should use channel or extension-specific
|
||||
subpaths; use `core` plus explicit domain subpaths for generic surfaces, and
|
||||
treat `compat` as migration-only.
|
||||
- Capability-specific subpaths such as `image-generation`,
|
||||
`media-understanding`, and `speech` exist because bundled/native plugins use
|
||||
them today. Their presence does not by itself mean every exported helper is a
|
||||
long-term frozen external contract.
|
||||
|
||||
## Read-only channel inspection
|
||||
|
||||
@@ -1332,6 +1351,7 @@ Compatible bundles may instead provide one of:
|
||||
|
||||
- `.codex-plugin/plugin.json`
|
||||
- `.claude-plugin/plugin.json`
|
||||
- `.cursor-plugin/plugin.json`
|
||||
|
||||
Bundle directories are discovered from the same roots as native plugins.
|
||||
|
||||
@@ -1536,7 +1556,8 @@ Fields:
|
||||
- `slots`: exclusive slot selectors such as `memory` and `contextEngine`
|
||||
- `entries.<id>`: per‑plugin toggles + config
|
||||
|
||||
Config changes **require a gateway restart**.
|
||||
Config changes **require a gateway restart**. See
|
||||
[Configuration reference](/configuration) for the full config schema.
|
||||
|
||||
Validation rules (strict):
|
||||
|
||||
@@ -1582,6 +1603,7 @@ Supported exclusive slots:
|
||||
|
||||
If multiple plugins declare `kind: "memory"` or `kind: "context-engine"`, only
|
||||
the selected plugin loads for that slot. Others are disabled with diagnostics.
|
||||
Declare `kind` in your [plugin manifest](/plugins/manifest).
|
||||
|
||||
### Context engine plugins
|
||||
|
||||
@@ -1636,7 +1658,7 @@ openclaw plugins install ./extensions/voice-call # relative path ok
|
||||
openclaw plugins install ./plugin.tgz # install from a local tarball
|
||||
openclaw plugins install ./plugin.zip # install from a local zip
|
||||
openclaw plugins install -l ./extensions/voice-call # link (no copy) for dev
|
||||
openclaw plugins install @openclaw/voice-call # install from npm
|
||||
openclaw plugins install @openclaw/voice-call # install from npm
|
||||
openclaw plugins install @openclaw/voice-call --pin # store exact resolved name@version
|
||||
openclaw plugins update <id>
|
||||
openclaw plugins update --all
|
||||
@@ -1645,14 +1667,11 @@ 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.
|
||||
See [`openclaw plugins` CLI reference](/cli/plugins) for full details on each
|
||||
command (install rules, inspect output, marketplace installs, uninstall).
|
||||
|
||||
`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).
|
||||
|
||||
Plugins may also register their own top‑level commands (example: `openclaw voicecall`).
|
||||
Plugins may also register their own top-level commands (example:
|
||||
`openclaw voicecall`).
|
||||
|
||||
## Plugin API (overview)
|
||||
|
||||
@@ -1710,7 +1729,8 @@ Recommended sequence:
|
||||
Add tests so ownership and registration shape stay explicit over time.
|
||||
|
||||
This is how OpenClaw stays opinionated without becoming hardcoded to one
|
||||
provider's worldview.
|
||||
provider's worldview. See the [Capability Cookbook](/tools/capability-cookbook)
|
||||
for a concrete file checklist and worked example.
|
||||
|
||||
### Capability checklist
|
||||
|
||||
@@ -2459,7 +2479,7 @@ See [Voice Call](/plugins/voice-call) and `extensions/voice-call/README.md` for
|
||||
|
||||
## Safety notes
|
||||
|
||||
Plugins run in-process with the Gateway. Treat them as trusted code:
|
||||
Plugins run in-process with the Gateway (see [Execution model](#execution-model)):
|
||||
|
||||
- Only install plugins you trust.
|
||||
- Prefer `plugins.allow` allowlists.
|
||||
|
||||
@@ -242,7 +242,7 @@ http://localhost:5173/?gatewayUrl=wss://<gateway-host>:18789#token=<gateway-toke
|
||||
Notes:
|
||||
|
||||
- `gatewayUrl` is stored in localStorage after load and removed from the URL.
|
||||
- `token` is preferably imported from the URL fragment, stored in sessionStorage for the current browser tab session and selected gateway URL, and stripped from the URL; legacy `?token=` query params are also imported once for compatibility and then removed.
|
||||
- `token` should be passed via the URL fragment (`#token=...`) whenever possible. Fragments are not sent to the server, which avoids request-log and Referer leakage. Legacy `?token=` query params are still imported once for compatibility, but only as a fallback, and are stripped immediately after bootstrap.
|
||||
- `password` is kept in memory only.
|
||||
- When `gatewayUrl` is set, the UI does not fall back to config or environment credentials.
|
||||
Provide `token` (or `password`) explicitly. Missing explicit credentials is an error.
|
||||
|
||||
@@ -19,4 +19,27 @@ describe("amazon-bedrock provider plugin", () => {
|
||||
} as never),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("disables prompt caching for non-Anthropic Bedrock models", () => {
|
||||
const provider = registerSingleProviderPlugin(amazonBedrockPlugin);
|
||||
const wrapped = provider.wrapStreamFn?.({
|
||||
provider: "amazon-bedrock",
|
||||
modelId: "amazon.nova-micro-v1:0",
|
||||
streamFn: (_model, _context, options) => options,
|
||||
} as never);
|
||||
|
||||
expect(
|
||||
wrapped?.(
|
||||
{
|
||||
api: "openai-completions",
|
||||
provider: "amazon-bedrock",
|
||||
id: "amazon.nova-micro-v1:0",
|
||||
} as never,
|
||||
{ messages: [] } as never,
|
||||
{},
|
||||
),
|
||||
).toMatchObject({
|
||||
cacheRetention: "none",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
createBedrockNoCacheWrapper,
|
||||
isAnthropicBedrockModel,
|
||||
} from "openclaw/plugin-sdk/provider-stream";
|
||||
|
||||
const PROVIDER_ID = "amazon-bedrock";
|
||||
const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i;
|
||||
@@ -13,6 +17,8 @@ export default definePluginEntry({
|
||||
label: "Amazon Bedrock",
|
||||
docsPath: "/providers/models",
|
||||
auth: [],
|
||||
wrapStreamFn: ({ modelId, streamFn }) =>
|
||||
isAnthropicBedrockModel(modelId) ? streamFn : createBedrockNoCacheWrapper(streamFn),
|
||||
resolveDefaultThinkingLevel: ({ modelId }) =>
|
||||
CLAUDE_46_MODEL_RE.test(modelId.trim()) ? "adaptive" : undefined,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { normalizeResolvedSecretInputString } from "./secret-input.js";
|
||||
|
||||
export type BlueBubblesAccountResolveOpts = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { createAccountListHelpers, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { createAccountListHelpers, type OpenClawConfig } from "./runtime-api.js";
|
||||
import { hasConfiguredSecretInput, normalizeSecretInputString } from "./secret-input.js";
|
||||
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ vi.mock("./probe.js", () => ({
|
||||
}));
|
||||
|
||||
describe("bluebubblesMessageActions", () => {
|
||||
const listActions = bluebubblesMessageActions.listActions!;
|
||||
const describeMessageTool = bluebubblesMessageActions.describeMessageTool!;
|
||||
const supportsAction = bluebubblesMessageActions.supportsAction!;
|
||||
const extractToolSend = bluebubblesMessageActions.extractToolSend!;
|
||||
const handleAction = bluebubblesMessageActions.handleAction!;
|
||||
@@ -74,12 +74,12 @@ describe("bluebubblesMessageActions", () => {
|
||||
vi.mocked(getCachedBlueBubblesPrivateApiStatus).mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe("listActions", () => {
|
||||
describe("describeMessageTool", () => {
|
||||
it("returns empty array when account is not enabled", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { bluebubbles: { enabled: false } },
|
||||
};
|
||||
const actions = listActions({ cfg });
|
||||
const actions = describeMessageTool({ cfg })?.actions ?? [];
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { bluebubbles: { enabled: true } },
|
||||
};
|
||||
const actions = listActions({ cfg });
|
||||
const actions = describeMessageTool({ cfg })?.actions ?? [];
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -101,7 +101,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const actions = listActions({ cfg });
|
||||
const actions = describeMessageTool({ cfg })?.actions ?? [];
|
||||
expect(actions).toContain("react");
|
||||
});
|
||||
|
||||
@@ -116,7 +116,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const actions = listActions({ cfg });
|
||||
const actions = describeMessageTool({ cfg })?.actions ?? [];
|
||||
expect(actions).not.toContain("react");
|
||||
// Other actions should still be present
|
||||
expect(actions).toContain("edit");
|
||||
@@ -134,7 +134,7 @@ describe("bluebubblesMessageActions", () => {
|
||||
},
|
||||
},
|
||||
};
|
||||
const actions = listActions({ cfg });
|
||||
const actions = describeMessageTool({ cfg })?.actions ?? [];
|
||||
expect(actions).toContain("sendAttachment");
|
||||
expect(actions).not.toContain("react");
|
||||
expect(actions).not.toContain("reply");
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
|
||||
import {
|
||||
BLUEBUBBLES_ACTION_NAMES,
|
||||
BLUEBUBBLES_ACTIONS,
|
||||
@@ -10,10 +13,7 @@ import {
|
||||
readStringParam,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelMessageActionName,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus, isMacOS26OrHigher } from "./probe.js";
|
||||
} from "./runtime-api.js";
|
||||
import { normalizeSecretInputString } from "./secret-input.js";
|
||||
import {
|
||||
normalizeBlueBubblesHandle,
|
||||
@@ -67,10 +67,10 @@ const PRIVATE_API_ACTIONS = new Set<ChannelMessageActionName>([
|
||||
]);
|
||||
|
||||
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
listActions: ({ cfg, currentChannelId }) => {
|
||||
describeMessageTool: ({ cfg, currentChannelId }) => {
|
||||
const account = resolveBlueBubblesAccount({ cfg: cfg });
|
||||
if (!account.enabled || !account.configured) {
|
||||
return [];
|
||||
return null;
|
||||
}
|
||||
const gate = createActionGate(cfg.channels?.bluebubbles?.actions);
|
||||
const actions = new Set<ChannelMessageActionName>();
|
||||
@@ -107,7 +107,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(actions);
|
||||
return { actions: Array.from(actions) };
|
||||
},
|
||||
supportsAction: ({ action }) => SUPPORTED_ACTIONS.has(action),
|
||||
extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
|
||||
import {
|
||||
@@ -8,6 +7,7 @@ import {
|
||||
isBlueBubblesPrivateApiStatusEnabled,
|
||||
} from "./probe.js";
|
||||
import { resolveRequestUrl } from "./request-url.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { getBlueBubblesRuntime, warnBlueBubbles } from "./runtime.js";
|
||||
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
|
||||
import { resolveChatGuidForTarget } from "./send.js";
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
||||
import type { ChannelAccountSnapshot, ChannelPlugin } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildProbeChannelStatusSummary,
|
||||
collectBlueBubblesStatusIssues,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
deleteAccountFromConfigSection,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveBlueBubblesGroupRequireMention,
|
||||
resolveBlueBubblesGroupToolPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
createScopedChannelConfigAdapter,
|
||||
createScopedDmSecurityResolver,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
collectOpenGroupPolicyRestrictSendersWarnings,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
import { collectOpenGroupPolicyRestrictSendersWarnings } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import {
|
||||
listBlueBubblesAccountIds,
|
||||
@@ -28,10 +15,26 @@ import {
|
||||
import { bluebubblesMessageActions } from "./actions.js";
|
||||
import type { BlueBubblesProbe } from "./channel.runtime.js";
|
||||
import { BlueBubblesConfigSchema } from "./config-schema.js";
|
||||
import {
|
||||
resolveBlueBubblesGroupRequireMention,
|
||||
resolveBlueBubblesGroupToolPolicy,
|
||||
} from "./group-policy.js";
|
||||
import type { ChannelAccountSnapshot, ChannelPlugin } from "./runtime-api.js";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildProbeChannelStatusSummary,
|
||||
collectBlueBubblesStatusIssues,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
} from "./runtime-api.js";
|
||||
import { resolveBlueBubblesOutboundSessionRoute } from "./session-route.js";
|
||||
import { blueBubblesSetupAdapter } from "./setup-core.js";
|
||||
import { blueBubblesSetupWizard } from "./setup-surface.js";
|
||||
import {
|
||||
extractHandleFromChatGuid,
|
||||
inferBlueBubblesTargetChatType,
|
||||
looksLikeBlueBubblesExplicitTargetId,
|
||||
looksLikeBlueBubblesTargetId,
|
||||
normalizeBlueBubblesHandle,
|
||||
normalizeBlueBubblesMessagingTarget,
|
||||
@@ -43,6 +46,28 @@ const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport(
|
||||
"blueBubblesChannelRuntime",
|
||||
);
|
||||
|
||||
const bluebubblesConfigAdapter = createScopedChannelConfigAdapter<ResolvedBlueBubblesAccount>({
|
||||
sectionKey: "bluebubbles",
|
||||
listAccountIds: listBlueBubblesAccountIds,
|
||||
resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg, accountId }),
|
||||
defaultAccountId: resolveDefaultBlueBubblesAccountId,
|
||||
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
|
||||
resolveAllowFrom: (account: ResolvedBlueBubblesAccount) => account.config.allowFrom,
|
||||
formatAllowFrom: (allowFrom) =>
|
||||
formatNormalizedAllowFromEntries({
|
||||
allowFrom,
|
||||
normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
|
||||
}),
|
||||
});
|
||||
|
||||
const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver<ResolvedBlueBubblesAccount>({
|
||||
channelKey: "bluebubbles",
|
||||
resolvePolicy: (account) => account.config.dmPolicy,
|
||||
resolveAllowFrom: (account) => account.config.allowFrom,
|
||||
policyPathSuffix: "dmPolicy",
|
||||
normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
|
||||
});
|
||||
|
||||
const meta = {
|
||||
id: "bluebubbles",
|
||||
label: "BlueBubbles",
|
||||
@@ -85,24 +110,7 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
|
||||
setupWizard: blueBubblesSetupWizard,
|
||||
config: {
|
||||
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveBlueBubblesAccount({ cfg: cfg, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg: cfg,
|
||||
sectionKey: "bluebubbles",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg: cfg,
|
||||
sectionKey: "bluebubbles",
|
||||
accountId,
|
||||
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
|
||||
}),
|
||||
...bluebubblesConfigAdapter,
|
||||
isConfigured: (account) => account.configured,
|
||||
describeAccount: (account): ChannelAccountSnapshot => ({
|
||||
accountId: account.accountId,
|
||||
@@ -111,28 +119,10 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
configured: account.configured,
|
||||
baseUrl: account.baseUrl,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
mapAllowFromEntries(resolveBlueBubblesAccount({ cfg: cfg, accountId }).config.allowFrom),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
formatNormalizedAllowFromEntries({
|
||||
allowFrom,
|
||||
normalizeEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
|
||||
}),
|
||||
},
|
||||
actions: bluebubblesMessageActions,
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
return buildAccountScopedDmSecurityPolicy({
|
||||
cfg,
|
||||
channelKey: "bluebubbles",
|
||||
accountId,
|
||||
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
|
||||
policy: account.config.dmPolicy,
|
||||
allowFrom: account.config.allowFrom ?? [],
|
||||
policyPathSuffix: "dmPolicy",
|
||||
normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
|
||||
});
|
||||
},
|
||||
resolveDmPolicy: resolveBlueBubblesDmPolicy,
|
||||
collectWarnings: ({ account }) => {
|
||||
const groupPolicy = account.config.groupPolicy ?? "allowlist";
|
||||
return collectOpenGroupPolicyRestrictSendersWarnings({
|
||||
@@ -147,9 +137,26 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeBlueBubblesMessagingTarget,
|
||||
inferTargetChatType: ({ to }) => inferBlueBubblesTargetChatType(to),
|
||||
resolveOutboundSessionRoute: (params) => resolveBlueBubblesOutboundSessionRoute(params),
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeBlueBubblesTargetId,
|
||||
looksLikeId: looksLikeBlueBubblesExplicitTargetId,
|
||||
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
|
||||
resolveTarget: async ({ normalized }) => {
|
||||
const to = normalized?.trim();
|
||||
if (!to) {
|
||||
return null;
|
||||
}
|
||||
const chatType = inferBlueBubblesTargetChatType(to);
|
||||
if (!chatType) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
to,
|
||||
kind: chatType === "direct" ? "user" : "group",
|
||||
source: "normalized" as const,
|
||||
};
|
||||
},
|
||||
},
|
||||
formatTargetDisplay: ({ target, display }) => {
|
||||
const shouldParseDisplay = (value: string): boolean => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { assertMultipartActionOk, postMultipartFormData } from "./multipart.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
export type BlueBubblesChatOpts = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DEFAULT_ACCOUNT_ID, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { DEFAULT_ACCOUNT_ID, type OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
type BlueBubblesConfigPatch = {
|
||||
serverUrl?: string;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import {
|
||||
AllowFromListSchema,
|
||||
buildCatchallMultiAccountChannelSchema,
|
||||
@@ -6,6 +5,7 @@ import {
|
||||
GroupPolicySchema,
|
||||
} from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import { z } from "zod";
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "./runtime-api.js";
|
||||
import { buildSecretInputSchema, hasConfiguredSecretInput } from "./secret-input.js";
|
||||
|
||||
const bluebubblesActionSchema = z
|
||||
|
||||
36
extensions/bluebubbles/src/group-policy.test.ts
Normal file
36
extensions/bluebubbles/src/group-policy.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveBlueBubblesGroupRequireMention,
|
||||
resolveBlueBubblesGroupToolPolicy,
|
||||
} from "./group-policy.js";
|
||||
|
||||
describe("bluebubbles group policy", () => {
|
||||
it("uses generic channel group policy helpers", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
groups: {
|
||||
"chat:primary": {
|
||||
requireMention: false,
|
||||
tools: { deny: ["exec"] },
|
||||
},
|
||||
"*": {
|
||||
requireMention: true,
|
||||
tools: { allow: ["message.send"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:primary" })).toBe(false);
|
||||
expect(resolveBlueBubblesGroupRequireMention({ cfg, groupId: "chat:other" })).toBe(true);
|
||||
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:primary" })).toEqual({
|
||||
deny: ["exec"],
|
||||
});
|
||||
expect(resolveBlueBubblesGroupToolPolicy({ cfg, groupId: "chat:other" })).toEqual({
|
||||
allow: ["message.send"],
|
||||
});
|
||||
});
|
||||
});
|
||||
40
extensions/bluebubbles/src/group-policy.ts
Normal file
40
extensions/bluebubbles/src/group-policy.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import {
|
||||
resolveChannelGroupRequireMention,
|
||||
resolveChannelGroupToolsPolicy,
|
||||
type GroupToolPolicyConfig,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
|
||||
type BlueBubblesGroupContext = {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
groupId?: string | null;
|
||||
senderId?: string | null;
|
||||
senderName?: string | null;
|
||||
senderUsername?: string | null;
|
||||
senderE164?: string | null;
|
||||
};
|
||||
|
||||
export function resolveBlueBubblesGroupRequireMention(params: BlueBubblesGroupContext): boolean {
|
||||
return resolveChannelGroupRequireMention({
|
||||
cfg: params.cfg,
|
||||
channel: "bluebubbles",
|
||||
groupId: params.groupId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveBlueBubblesGroupToolPolicy(
|
||||
params: BlueBubblesGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
return resolveChannelGroupToolsPolicy({
|
||||
cfg: params.cfg,
|
||||
channel: "bluebubbles",
|
||||
groupId: params.groupId,
|
||||
accountId: params.accountId,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
export type BlueBubblesHistoryEntry = {
|
||||
|
||||
@@ -3,10 +3,10 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { sendBlueBubblesAttachment } from "./attachments.js";
|
||||
import { resolveBlueBubblesMessageId } from "./monitor.js";
|
||||
import { resolveChannelMediaMaxBytes, type OpenClawConfig } from "./runtime-api.js";
|
||||
import { getBlueBubblesRuntime } from "./runtime.js";
|
||||
import { sendMessageBlueBubbles } from "./send.js";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { NormalizedWebhookMessage } from "./monitor-normalize.js";
|
||||
import type { BlueBubblesCoreRuntime, WebhookTarget } from "./monitor-shared.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
/**
|
||||
* Entry type for debouncing inbound messages.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseFiniteNumber } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { parseFiniteNumber } from "./runtime-api.js";
|
||||
import { extractHandleFromChatGuid, normalizeBlueBubblesHandle } from "./targets.js";
|
||||
import type { BlueBubblesAttachment } from "./types.js";
|
||||
|
||||
|
||||
@@ -1,22 +1,3 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import {
|
||||
DM_GROUP_ACCESS_REASON,
|
||||
createScopedPairingAccess,
|
||||
createReplyPrefixOptions,
|
||||
evictOldHistoryKeys,
|
||||
issuePairingChallenge,
|
||||
logAckFailure,
|
||||
logInboundDrop,
|
||||
logTypingFailure,
|
||||
mapAllowFromEntries,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
resolveAckReaction,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveControlCommandGate,
|
||||
stripMarkdown,
|
||||
type HistoryEntry,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||
import { markBlueBubblesChatRead, sendBlueBubblesTyping } from "./chat.js";
|
||||
import { fetchBlueBubblesHistory } from "./history.js";
|
||||
@@ -49,6 +30,25 @@ import type {
|
||||
} from "./monitor-shared.js";
|
||||
import { isBlueBubblesPrivateApiEnabled } from "./probe.js";
|
||||
import { normalizeBlueBubblesReactionInput, sendBlueBubblesReaction } from "./reactions.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import {
|
||||
DM_GROUP_ACCESS_REASON,
|
||||
createScopedPairingAccess,
|
||||
createReplyPrefixOptions,
|
||||
evictOldHistoryKeys,
|
||||
issuePairingChallenge,
|
||||
logAckFailure,
|
||||
logInboundDrop,
|
||||
logTypingFailure,
|
||||
mapAllowFromEntries,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
recordPendingHistoryEntryIfEnabled,
|
||||
resolveAckReaction,
|
||||
resolveDmGroupAccessWithLists,
|
||||
resolveControlCommandGate,
|
||||
stripMarkdown,
|
||||
type HistoryEntry,
|
||||
} from "./runtime-api.js";
|
||||
import { normalizeSecretInputString } from "./secret-input.js";
|
||||
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { normalizeWebhookPath, type OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
|
||||
import { normalizeWebhookPath, type OpenClawConfig } from "./runtime-api.js";
|
||||
import { getBlueBubblesRuntime } from "./runtime.js";
|
||||
import type { BlueBubblesAccountConfig } from "./types.js";
|
||||
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import {
|
||||
createWebhookInFlightLimiter,
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
readWebhookBodyOrReject,
|
||||
resolveWebhookTargetWithAuthOrRejectSync,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { createBlueBubblesDebounceRegistry } from "./monitor-debounce.js";
|
||||
import { normalizeWebhookMessage, normalizeWebhookReaction } from "./monitor-normalize.js";
|
||||
import { logVerbose, processMessage, processReaction } from "./monitor-processing.js";
|
||||
@@ -22,6 +15,13 @@ import {
|
||||
type WebhookTarget,
|
||||
} from "./monitor-shared.js";
|
||||
import { fetchBlueBubblesServerInfo } from "./probe.js";
|
||||
import {
|
||||
createWebhookInFlightLimiter,
|
||||
registerWebhookTargetWithPluginRoute,
|
||||
readWebhookBodyOrReject,
|
||||
resolveWebhookTargetWithAuthOrRejectSync,
|
||||
withResolvedWebhookRequestPipeline,
|
||||
} from "./runtime-api.js";
|
||||
import { getBlueBubblesRuntime } from "./runtime.js";
|
||||
|
||||
const webhookTargets = new Map<string, WebhookTarget[]>();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { BaseProbeResult } from "./runtime-api.js";
|
||||
import { normalizeSecretInputString } from "./secret-input.js";
|
||||
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
|
||||
|
||||
@@ -73,7 +73,7 @@ export async function fetchBlueBubblesServerInfo(params: {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached server info synchronously (for use in listActions).
|
||||
* Get cached server info synchronously (for use in describeMessageTool).
|
||||
* Returns null if not cached or expired.
|
||||
*/
|
||||
export function getCachedBlueBubblesServerInfo(accountId?: string): BlueBubblesServerInfo | null {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { resolveBlueBubblesServerAccount } from "./account-resolve.js";
|
||||
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
export type BlueBubblesReactionOpts = {
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { resolveRequestUrl } from "openclaw/plugin-sdk/bluebubbles";
|
||||
export { resolveRequestUrl } from "./runtime-api.js";
|
||||
|
||||
1
extensions/bluebubbles/src/runtime-api.ts
Normal file
1
extensions/bluebubbles/src/runtime-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "openclaw/plugin-sdk/bluebubbles";
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "./runtime-api.js";
|
||||
|
||||
const runtimeStore = createPluginRuntimeStore<PluginRuntime>("BlueBubbles runtime not initialized");
|
||||
type LegacyRuntimeLogShape = { log?: (message: string) => void };
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
} from "./runtime-api.js";
|
||||
|
||||
export {
|
||||
buildSecretInputSchema,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { stripMarkdown } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import {
|
||||
getCachedBlueBubblesPrivateApiStatus,
|
||||
isBlueBubblesPrivateApiStatusEnabled,
|
||||
} from "./probe.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import { stripMarkdown } from "./runtime-api.js";
|
||||
import { warnBlueBubbles } from "./runtime.js";
|
||||
import { normalizeSecretInputString } from "./secret-input.js";
|
||||
import { extractBlueBubblesMessageId, resolveBlueBubblesSendTarget } from "./send-helpers.js";
|
||||
|
||||
37
extensions/bluebubbles/src/session-route.ts
Normal file
37
extensions/bluebubbles/src/session-route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
buildChannelOutboundSessionRoute,
|
||||
stripChannelTargetPrefix,
|
||||
type ChannelOutboundSessionRouteParams,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import { parseBlueBubblesTarget } from "./targets.js";
|
||||
|
||||
export function resolveBlueBubblesOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
|
||||
const stripped = stripChannelTargetPrefix(params.target, "bluebubbles");
|
||||
if (!stripped) {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseBlueBubblesTarget(stripped);
|
||||
const isGroup =
|
||||
parsed.kind === "chat_id" || parsed.kind === "chat_guid" || parsed.kind === "chat_identifier";
|
||||
const peerId =
|
||||
parsed.kind === "chat_id"
|
||||
? String(parsed.chatId)
|
||||
: parsed.kind === "chat_guid"
|
||||
? parsed.chatGuid
|
||||
: parsed.kind === "chat_identifier"
|
||||
? parsed.chatIdentifier
|
||||
: parsed.to;
|
||||
return buildChannelOutboundSessionRoute({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
channel: "bluebubbles",
|
||||
accountId: params.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
},
|
||||
chatType: isGroup ? "group" : "direct",
|
||||
from: isGroup ? `group:${peerId}` : `bluebubbles:${peerId}`,
|
||||
to: `bluebubbles:${stripped}`,
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
createTopLevelChannelDmPolicySetter,
|
||||
normalizeAccountId,
|
||||
patchScopedAccountConfig,
|
||||
prepareScopedSetupConfig,
|
||||
setTopLevelChannelDmPolicyWithAllowFrom,
|
||||
type ChannelSetupAdapter,
|
||||
type DmPolicy,
|
||||
type OpenClawConfig,
|
||||
@@ -10,13 +10,12 @@ import {
|
||||
import { applyBlueBubblesConnectionConfig } from "./config-apply.js";
|
||||
|
||||
const channel = "bluebubbles" as const;
|
||||
const setBlueBubblesTopLevelDmPolicy = createTopLevelChannelDmPolicySetter({
|
||||
channel,
|
||||
});
|
||||
|
||||
export function setBlueBubblesDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy): OpenClawConfig {
|
||||
return setTopLevelChannelDmPolicyWithAllowFrom({
|
||||
cfg,
|
||||
channel,
|
||||
dmPolicy,
|
||||
});
|
||||
return setBlueBubblesTopLevelDmPolicy(cfg, dmPolicy);
|
||||
}
|
||||
|
||||
export function setBlueBubblesAllowFrom(
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import {
|
||||
createAllowFromSection,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatDocsLink,
|
||||
mergeAllowFromEntries,
|
||||
resolveSetupAccountId,
|
||||
promptParsedAllowFromForAccount,
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizard,
|
||||
type DmPolicy,
|
||||
type OpenClawConfig,
|
||||
type WizardPrompter,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
@@ -55,14 +54,13 @@ async function promptBlueBubblesAllowFrom(params: {
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const accountId = resolveSetupAccountId({
|
||||
return await promptParsedAllowFromForAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
defaultAccountId: resolveDefaultBlueBubblesAccountId(params.cfg),
|
||||
});
|
||||
const resolved = resolveBlueBubblesAccount({ cfg: params.cfg, accountId });
|
||||
const existing = resolved.config.allowFrom ?? [];
|
||||
await params.prompter.note(
|
||||
[
|
||||
prompter: params.prompter,
|
||||
noteTitle: "BlueBubbles allowlist",
|
||||
noteLines: [
|
||||
"Allowlist BlueBubbles DMs by handle or chat target.",
|
||||
"Examples:",
|
||||
"- +15555550123",
|
||||
@@ -71,30 +69,23 @@ async function promptBlueBubblesAllowFrom(params: {
|
||||
"- 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}`;
|
||||
parseEntries: (raw) => {
|
||||
const entries = parseBlueBubblesAllowFromInput(raw);
|
||||
for (const entry of entries) {
|
||||
if (!validateBlueBubblesAllowFromEntry(entry)) {
|
||||
return { entries: [], error: `Invalid entry: ${entry}` };
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
return { entries };
|
||||
},
|
||||
getExistingAllowFrom: ({ cfg, accountId }) =>
|
||||
resolveBlueBubblesAccount({ cfg, accountId }).config.allowFrom ?? [],
|
||||
applyAllowFrom: ({ cfg, accountId, allowFrom }) =>
|
||||
setBlueBubblesAllowFrom(cfg, accountId, allowFrom),
|
||||
});
|
||||
const parts = parseBlueBubblesAllowFromInput(String(entry));
|
||||
const unique = mergeAllowFromEntries(undefined, parts);
|
||||
return setBlueBubblesAllowFrom(params.cfg, accountId, unique);
|
||||
}
|
||||
|
||||
function validateBlueBubblesServerUrlInput(value: unknown): string | undefined {
|
||||
@@ -272,7 +263,7 @@ export const blueBubblesSetupWizard: ChannelSetupWizard = {
|
||||
],
|
||||
},
|
||||
dmPolicy,
|
||||
allowFrom: {
|
||||
allowFrom: createAllowFromSection({
|
||||
helpTitle: "BlueBubbles allowlist",
|
||||
helpLines: [
|
||||
"Allowlist BlueBubbles DMs by handle or chat target.",
|
||||
@@ -290,15 +281,9 @@ export const blueBubblesSetupWizard: ChannelSetupWizard = {
|
||||
"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: {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
inferBlueBubblesTargetChatType,
|
||||
looksLikeBlueBubblesExplicitTargetId,
|
||||
isAllowedBlueBubblesSender,
|
||||
looksLikeBlueBubblesTargetId,
|
||||
normalizeBlueBubblesMessagingTarget,
|
||||
@@ -101,6 +103,30 @@ describe("looksLikeBlueBubblesTargetId", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeBlueBubblesExplicitTargetId", () => {
|
||||
it("treats explicit chat targets as immediate ids", () => {
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("chat_guid:ABC-123")).toBe(true);
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("imessage:+15551234567")).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers directory fallback for bare handles and phone numbers", () => {
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("+1 (555) 123-4567")).toBe(false);
|
||||
expect(looksLikeBlueBubblesExplicitTargetId("user@example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inferBlueBubblesTargetChatType", () => {
|
||||
it("infers direct chat for handles and dm chat_guids", () => {
|
||||
expect(inferBlueBubblesTargetChatType("+15551234567")).toBe("direct");
|
||||
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;-;+15551234567")).toBe("direct");
|
||||
});
|
||||
|
||||
it("infers group chat for explicit group targets", () => {
|
||||
expect(inferBlueBubblesTargetChatType("chat_id:123")).toBe("group");
|
||||
expect(inferBlueBubblesTargetChatType("chat_guid:iMessage;+;chat123")).toBe("group");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBlueBubblesTarget", () => {
|
||||
it("parses chat<digits> pattern as chat_identifier", () => {
|
||||
expect(parseBlueBubblesTarget("chat660250192681427962")).toEqual({
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type ParsedChatTarget,
|
||||
resolveServicePrefixedAllowTarget,
|
||||
resolveServicePrefixedTarget,
|
||||
} from "openclaw/plugin-sdk/bluebubbles";
|
||||
} from "./runtime-api.js";
|
||||
|
||||
export type BlueBubblesService = "imessage" | "sms" | "auto";
|
||||
|
||||
@@ -237,6 +237,63 @@ export function looksLikeBlueBubblesTargetId(raw: string, normalized?: string):
|
||||
return false;
|
||||
}
|
||||
|
||||
export function looksLikeBlueBubblesExplicitTargetId(raw: string, normalized?: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const candidate = stripBlueBubblesPrefix(trimmed);
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
const lowered = candidate.toLowerCase();
|
||||
if (/^(imessage|sms|auto):/.test(lowered)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
/^(chat_id|chatid|chat|chat_guid|chatguid|guid|chat_identifier|chatidentifier|chatident|group):/.test(
|
||||
lowered,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (parseRawChatGuid(candidate) || looksLikeRawChatIdentifier(candidate)) {
|
||||
return true;
|
||||
}
|
||||
if (normalized) {
|
||||
const normalizedTrimmed = normalized.trim();
|
||||
if (!normalizedTrimmed) {
|
||||
return false;
|
||||
}
|
||||
const normalizedLower = normalizedTrimmed.toLowerCase();
|
||||
if (
|
||||
/^(imessage|sms|auto):/.test(normalizedLower) ||
|
||||
/^(chat_id|chat_guid|chat_identifier):/.test(normalizedLower)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function inferBlueBubblesTargetChatType(raw: string): "direct" | "group" | undefined {
|
||||
try {
|
||||
const parsed = parseBlueBubblesTarget(raw);
|
||||
if (parsed.kind === "handle") {
|
||||
return "direct";
|
||||
}
|
||||
if (parsed.kind === "chat_guid") {
|
||||
return parsed.chatGuid.includes(";+;") ? "group" : "direct";
|
||||
}
|
||||
if (parsed.kind === "chat_id" || parsed.kind === "chat_identifier") {
|
||||
return "group";
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
|
||||
const trimmed = stripBlueBubblesPrefix(raw);
|
||||
if (!trimmed) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/bluebubbles";
|
||||
import type { DmPolicy, GroupPolicy } from "./runtime-api.js";
|
||||
|
||||
export type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/bluebubbles";
|
||||
export type { DmPolicy, GroupPolicy } from "./runtime-api.js";
|
||||
|
||||
export type BlueBubblesGroupConfig = {
|
||||
/** If true, only respond in this group when mentioned. */
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
createPluginBackedWebSearchProvider,
|
||||
getTopLevelCredentialValue,
|
||||
setTopLevelCredentialValue,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import { createBraveWebSearchProvider } from "./src/brave-web-search-provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "brave",
|
||||
name: "Brave Plugin",
|
||||
description: "Bundled Brave plugin",
|
||||
register(api) {
|
||||
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,
|
||||
}),
|
||||
);
|
||||
api.registerWebSearchProvider(createBraveWebSearchProvider());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,8 +1,34 @@
|
||||
{
|
||||
"id": "brave",
|
||||
"uiHints": {
|
||||
"webSearch.apiKey": {
|
||||
"label": "Brave Search API Key",
|
||||
"help": "Brave Search API key (fallback: BRAVE_API_KEY env var).",
|
||||
"sensitive": true,
|
||||
"placeholder": "BSA..."
|
||||
},
|
||||
"webSearch.mode": {
|
||||
"label": "Brave Search Mode",
|
||||
"help": "Brave Search mode: web or llm-context."
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
"properties": {
|
||||
"webSearch": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": ["string", "object"]
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["web", "llm-context"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
648
extensions/brave/src/brave-web-search-provider.ts
Normal file
648
extensions/brave/src/brave-web-search-provider.ts
Normal file
@@ -0,0 +1,648 @@
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { readNumberParam, readStringParam } from "../../../src/agents/tools/common.js";
|
||||
import type { SearchConfigRecord } from "../../../src/agents/tools/web-search-provider-common.js";
|
||||
import {
|
||||
buildSearchCacheKey,
|
||||
DEFAULT_SEARCH_COUNT,
|
||||
MAX_SEARCH_COUNT,
|
||||
normalizeFreshness,
|
||||
normalizeToIsoDate,
|
||||
readCachedSearchPayload,
|
||||
readConfiguredSecretString,
|
||||
readProviderEnvValue,
|
||||
resolveSearchCacheTtlMs,
|
||||
resolveSearchCount,
|
||||
resolveSearchTimeoutSeconds,
|
||||
resolveSiteName,
|
||||
withTrustedWebSearchEndpoint,
|
||||
writeCachedSearchPayload,
|
||||
} from "../../../src/agents/tools/web-search-provider-common.js";
|
||||
import {
|
||||
resolveProviderWebSearchPluginConfig,
|
||||
setProviderWebSearchPluginConfigValue,
|
||||
} from "../../../src/agents/tools/web-search-provider-config.js";
|
||||
import { formatCliCommand } from "../../../src/cli/command-format.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type {
|
||||
WebSearchProviderPlugin,
|
||||
WebSearchProviderToolDefinition,
|
||||
} from "../../../src/plugins/types.js";
|
||||
import { wrapWebContent } from "../../../src/security/external-content.js";
|
||||
|
||||
const BRAVE_SEARCH_ENDPOINT = "https://api.search.brave.com/res/v1/web/search";
|
||||
const BRAVE_LLM_CONTEXT_ENDPOINT = "https://api.search.brave.com/res/v1/llm/context";
|
||||
const BRAVE_SEARCH_LANG_CODES = new Set([
|
||||
"ar",
|
||||
"eu",
|
||||
"bn",
|
||||
"bg",
|
||||
"ca",
|
||||
"zh-hans",
|
||||
"zh-hant",
|
||||
"hr",
|
||||
"cs",
|
||||
"da",
|
||||
"nl",
|
||||
"en",
|
||||
"en-gb",
|
||||
"et",
|
||||
"fi",
|
||||
"fr",
|
||||
"gl",
|
||||
"de",
|
||||
"el",
|
||||
"gu",
|
||||
"he",
|
||||
"hi",
|
||||
"hu",
|
||||
"is",
|
||||
"it",
|
||||
"jp",
|
||||
"kn",
|
||||
"ko",
|
||||
"lv",
|
||||
"lt",
|
||||
"ms",
|
||||
"ml",
|
||||
"mr",
|
||||
"nb",
|
||||
"pl",
|
||||
"pt-br",
|
||||
"pt-pt",
|
||||
"pa",
|
||||
"ro",
|
||||
"ru",
|
||||
"sr",
|
||||
"sk",
|
||||
"sl",
|
||||
"es",
|
||||
"sv",
|
||||
"ta",
|
||||
"te",
|
||||
"th",
|
||||
"tr",
|
||||
"uk",
|
||||
"vi",
|
||||
]);
|
||||
const BRAVE_SEARCH_LANG_ALIASES: Record<string, string> = {
|
||||
ja: "jp",
|
||||
zh: "zh-hans",
|
||||
"zh-cn": "zh-hans",
|
||||
"zh-hk": "zh-hant",
|
||||
"zh-sg": "zh-hans",
|
||||
"zh-tw": "zh-hant",
|
||||
};
|
||||
const BRAVE_UI_LANG_LOCALE = /^([a-z]{2})-([a-z]{2})$/i;
|
||||
|
||||
type BraveConfig = {
|
||||
apiKey?: unknown;
|
||||
mode?: string;
|
||||
};
|
||||
|
||||
type BraveSearchResult = {
|
||||
title?: string;
|
||||
url?: string;
|
||||
description?: string;
|
||||
age?: string;
|
||||
};
|
||||
|
||||
type BraveSearchResponse = {
|
||||
web?: {
|
||||
results?: BraveSearchResult[];
|
||||
};
|
||||
};
|
||||
|
||||
type BraveLlmContextResult = { url: string; title: string; snippets: string[] };
|
||||
type BraveLlmContextResponse = {
|
||||
grounding: { generic?: BraveLlmContextResult[] };
|
||||
sources?: { url?: string; hostname?: string; date?: string }[];
|
||||
};
|
||||
|
||||
function resolveBraveConfig(
|
||||
config?: OpenClawConfig,
|
||||
searchConfig?: SearchConfigRecord,
|
||||
): BraveConfig {
|
||||
const pluginConfig = resolveProviderWebSearchPluginConfig(config, "brave");
|
||||
if (pluginConfig) {
|
||||
return pluginConfig as BraveConfig;
|
||||
}
|
||||
const scoped = (searchConfig as Record<string, unknown> | undefined)?.brave;
|
||||
return scoped && typeof scoped === "object" && !Array.isArray(scoped)
|
||||
? ({
|
||||
...(scoped as BraveConfig),
|
||||
apiKey: (searchConfig as Record<string, unknown> | undefined)?.apiKey,
|
||||
} as BraveConfig)
|
||||
: ({ apiKey: (searchConfig as Record<string, unknown> | undefined)?.apiKey } as BraveConfig);
|
||||
}
|
||||
|
||||
function resolveBraveMode(brave: BraveConfig): "web" | "llm-context" {
|
||||
return brave.mode === "llm-context" ? "llm-context" : "web";
|
||||
}
|
||||
|
||||
function resolveBraveApiKey(
|
||||
config?: OpenClawConfig,
|
||||
searchConfig?: SearchConfigRecord,
|
||||
): string | undefined {
|
||||
const braveConfig = resolveBraveConfig(config, searchConfig);
|
||||
return (
|
||||
readConfiguredSecretString(
|
||||
braveConfig.apiKey,
|
||||
"plugins.entries.brave.config.webSearch.apiKey",
|
||||
) ??
|
||||
readConfiguredSecretString(
|
||||
(searchConfig as Record<string, unknown> | undefined)?.apiKey,
|
||||
"tools.web.search.apiKey",
|
||||
) ??
|
||||
readProviderEnvValue(["BRAVE_API_KEY"])
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBraveSearchLang(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const canonical = BRAVE_SEARCH_LANG_ALIASES[trimmed.toLowerCase()] ?? trimmed.toLowerCase();
|
||||
if (!BRAVE_SEARCH_LANG_CODES.has(canonical)) {
|
||||
return undefined;
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
|
||||
function normalizeBraveUiLang(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
const match = trimmed.match(BRAVE_UI_LANG_LOCALE);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const [, language, region] = match;
|
||||
return `${language.toLowerCase()}-${region.toUpperCase()}`;
|
||||
}
|
||||
|
||||
function normalizeBraveLanguageParams(params: { search_lang?: string; ui_lang?: string }): {
|
||||
search_lang?: string;
|
||||
ui_lang?: string;
|
||||
invalidField?: "search_lang" | "ui_lang";
|
||||
} {
|
||||
const rawSearchLang = params.search_lang?.trim() || undefined;
|
||||
const rawUiLang = params.ui_lang?.trim() || undefined;
|
||||
let searchLangCandidate = rawSearchLang;
|
||||
let uiLangCandidate = rawUiLang;
|
||||
|
||||
if (normalizeBraveUiLang(rawSearchLang) && normalizeBraveSearchLang(rawUiLang)) {
|
||||
searchLangCandidate = rawUiLang;
|
||||
uiLangCandidate = rawSearchLang;
|
||||
}
|
||||
|
||||
const search_lang = normalizeBraveSearchLang(searchLangCandidate);
|
||||
if (searchLangCandidate && !search_lang) {
|
||||
return { invalidField: "search_lang" };
|
||||
}
|
||||
|
||||
const ui_lang = normalizeBraveUiLang(uiLangCandidate);
|
||||
if (uiLangCandidate && !ui_lang) {
|
||||
return { invalidField: "ui_lang" };
|
||||
}
|
||||
|
||||
return { search_lang, ui_lang };
|
||||
}
|
||||
|
||||
function mapBraveLlmContextResults(
|
||||
data: BraveLlmContextResponse,
|
||||
): { url: string; title: string; snippets: string[]; siteName?: string }[] {
|
||||
const genericResults = Array.isArray(data.grounding?.generic) ? data.grounding.generic : [];
|
||||
return genericResults.map((entry) => ({
|
||||
url: entry.url ?? "",
|
||||
title: entry.title ?? "",
|
||||
snippets: (entry.snippets ?? []).filter((s) => typeof s === "string" && s.length > 0),
|
||||
siteName: resolveSiteName(entry.url) || undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
async function runBraveLlmContextSearch(params: {
|
||||
query: string;
|
||||
apiKey: string;
|
||||
timeoutSeconds: number;
|
||||
country?: string;
|
||||
search_lang?: string;
|
||||
freshness?: string;
|
||||
}): Promise<{
|
||||
results: Array<{
|
||||
url: string;
|
||||
title: string;
|
||||
snippets: string[];
|
||||
siteName?: string;
|
||||
}>;
|
||||
sources?: BraveLlmContextResponse["sources"];
|
||||
}> {
|
||||
const url = new URL(BRAVE_LLM_CONTEXT_ENDPOINT);
|
||||
url.searchParams.set("q", params.query);
|
||||
if (params.country) {
|
||||
url.searchParams.set("country", params.country);
|
||||
}
|
||||
if (params.search_lang) {
|
||||
url.searchParams.set("search_lang", params.search_lang);
|
||||
}
|
||||
if (params.freshness) {
|
||||
url.searchParams.set("freshness", params.freshness);
|
||||
}
|
||||
|
||||
return withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: url.toString(),
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-Subscription-Token": params.apiKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
const detail = await res.text();
|
||||
throw new Error(`Brave LLM Context API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as BraveLlmContextResponse;
|
||||
return { results: mapBraveLlmContextResults(data), sources: data.sources };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function runBraveWebSearch(params: {
|
||||
query: string;
|
||||
count: number;
|
||||
apiKey: string;
|
||||
timeoutSeconds: number;
|
||||
country?: string;
|
||||
search_lang?: string;
|
||||
ui_lang?: string;
|
||||
freshness?: string;
|
||||
dateAfter?: string;
|
||||
dateBefore?: string;
|
||||
}): Promise<Array<Record<string, unknown>>> {
|
||||
const url = new URL(BRAVE_SEARCH_ENDPOINT);
|
||||
url.searchParams.set("q", params.query);
|
||||
url.searchParams.set("count", String(params.count));
|
||||
if (params.country) {
|
||||
url.searchParams.set("country", params.country);
|
||||
}
|
||||
if (params.search_lang) {
|
||||
url.searchParams.set("search_lang", params.search_lang);
|
||||
}
|
||||
if (params.ui_lang) {
|
||||
url.searchParams.set("ui_lang", params.ui_lang);
|
||||
}
|
||||
if (params.freshness) {
|
||||
url.searchParams.set("freshness", params.freshness);
|
||||
} else if (params.dateAfter && params.dateBefore) {
|
||||
url.searchParams.set("freshness", `${params.dateAfter}to${params.dateBefore}`);
|
||||
} else if (params.dateAfter) {
|
||||
url.searchParams.set(
|
||||
"freshness",
|
||||
`${params.dateAfter}to${new Date().toISOString().slice(0, 10)}`,
|
||||
);
|
||||
} else if (params.dateBefore) {
|
||||
url.searchParams.set("freshness", `1970-01-01to${params.dateBefore}`);
|
||||
}
|
||||
|
||||
return withTrustedWebSearchEndpoint(
|
||||
{
|
||||
url: url.toString(),
|
||||
timeoutSeconds: params.timeoutSeconds,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"X-Subscription-Token": params.apiKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (res) => {
|
||||
if (!res.ok) {
|
||||
const detail = await res.text();
|
||||
throw new Error(`Brave Search API error (${res.status}): ${detail || res.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as BraveSearchResponse;
|
||||
const results = Array.isArray(data.web?.results) ? (data.web?.results ?? []) : [];
|
||||
return results.map((entry) => {
|
||||
const description = entry.description ?? "";
|
||||
const title = entry.title ?? "";
|
||||
const url = entry.url ?? "";
|
||||
return {
|
||||
title: title ? wrapWebContent(title, "web_search") : "",
|
||||
url,
|
||||
description: description ? wrapWebContent(description, "web_search") : "",
|
||||
published: entry.age || undefined,
|
||||
siteName: resolveSiteName(url) || undefined,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function createBraveSchema() {
|
||||
return Type.Object({
|
||||
query: Type.String({ description: "Search query string." }),
|
||||
count: Type.Optional(
|
||||
Type.Number({
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: MAX_SEARCH_COUNT,
|
||||
}),
|
||||
),
|
||||
country: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"2-letter country code for region-specific results (e.g., 'DE', 'US', 'ALL'). Default: 'US'.",
|
||||
}),
|
||||
),
|
||||
language: Type.Optional(
|
||||
Type.String({
|
||||
description: "ISO 639-1 language code for results (e.g., 'en', 'de', 'fr').",
|
||||
}),
|
||||
),
|
||||
freshness: Type.Optional(
|
||||
Type.String({
|
||||
description: "Filter by time: 'day' (24h), 'week', 'month', or 'year'.",
|
||||
}),
|
||||
),
|
||||
date_after: Type.Optional(
|
||||
Type.String({
|
||||
description: "Only results published after this date (YYYY-MM-DD).",
|
||||
}),
|
||||
),
|
||||
date_before: Type.Optional(
|
||||
Type.String({
|
||||
description: "Only results published before this date (YYYY-MM-DD).",
|
||||
}),
|
||||
),
|
||||
search_lang: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Brave language code for search results (e.g., 'en', 'de', 'en-gb', 'zh-hans', 'zh-hant', 'pt-br').",
|
||||
}),
|
||||
),
|
||||
ui_lang: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Locale code for UI elements in language-region format (e.g., 'en-US', 'de-DE', 'fr-FR', 'tr-TR'). Must include region subtag.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function missingBraveKeyPayload() {
|
||||
return {
|
||||
error: "missing_brave_api_key",
|
||||
message: `web_search (brave) needs a Brave Search API key. Run \`${formatCliCommand("openclaw configure --section web")}\` to store it, or set BRAVE_API_KEY in the Gateway environment.`,
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
function createBraveToolDefinition(
|
||||
config?: OpenClawConfig,
|
||||
searchConfig?: SearchConfigRecord,
|
||||
): WebSearchProviderToolDefinition {
|
||||
const braveConfig = resolveBraveConfig(config, searchConfig);
|
||||
const braveMode = resolveBraveMode(braveConfig);
|
||||
|
||||
return {
|
||||
description:
|
||||
braveMode === "llm-context"
|
||||
? "Search the web using Brave Search LLM Context API. Returns pre-extracted page content (text chunks, tables, code blocks) optimized for LLM grounding."
|
||||
: "Search the web using Brave Search API. Supports region-specific and localized search via country and language parameters. Returns titles, URLs, and snippets for fast research.",
|
||||
parameters: createBraveSchema(),
|
||||
execute: async (args) => {
|
||||
const apiKey = resolveBraveApiKey(config, searchConfig);
|
||||
if (!apiKey) {
|
||||
return missingBraveKeyPayload();
|
||||
}
|
||||
|
||||
const params = args as Record<string, unknown>;
|
||||
const query = readStringParam(params, "query", { required: true });
|
||||
const count =
|
||||
readNumberParam(params, "count", { integer: true }) ??
|
||||
searchConfig?.maxResults ??
|
||||
undefined;
|
||||
const country = readStringParam(params, "country");
|
||||
const language = readStringParam(params, "language");
|
||||
const search_lang = readStringParam(params, "search_lang");
|
||||
const ui_lang = readStringParam(params, "ui_lang");
|
||||
const normalizedLanguage = normalizeBraveLanguageParams({
|
||||
search_lang: search_lang || language,
|
||||
ui_lang,
|
||||
});
|
||||
if (normalizedLanguage.invalidField === "search_lang") {
|
||||
return {
|
||||
error: "invalid_search_lang",
|
||||
message:
|
||||
"search_lang must be a Brave-supported language code like 'en', 'en-gb', 'zh-hans', or 'zh-hant'.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (normalizedLanguage.invalidField === "ui_lang") {
|
||||
return {
|
||||
error: "invalid_ui_lang",
|
||||
message: "ui_lang must be a language-region locale like 'en-US'.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (normalizedLanguage.ui_lang && braveMode === "llm-context") {
|
||||
return {
|
||||
error: "unsupported_ui_lang",
|
||||
message:
|
||||
"ui_lang is not supported by Brave llm-context mode. Remove ui_lang or use Brave web mode for locale-based UI hints.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const rawFreshness = readStringParam(params, "freshness");
|
||||
if (rawFreshness && braveMode === "llm-context") {
|
||||
return {
|
||||
error: "unsupported_freshness",
|
||||
message:
|
||||
"freshness filtering is not supported by Brave llm-context mode. Remove freshness or use Brave web mode.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
const freshness = rawFreshness ? normalizeFreshness(rawFreshness, "brave") : undefined;
|
||||
if (rawFreshness && !freshness) {
|
||||
return {
|
||||
error: "invalid_freshness",
|
||||
message: "freshness must be day, week, month, or year.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const rawDateAfter = readStringParam(params, "date_after");
|
||||
const rawDateBefore = readStringParam(params, "date_before");
|
||||
if (rawFreshness && (rawDateAfter || rawDateBefore)) {
|
||||
return {
|
||||
error: "conflicting_time_filters",
|
||||
message:
|
||||
"freshness and date_after/date_before cannot be used together. Use either freshness (day/week/month/year) or a date range (date_after/date_before), not both.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if ((rawDateAfter || rawDateBefore) && braveMode === "llm-context") {
|
||||
return {
|
||||
error: "unsupported_date_filter",
|
||||
message:
|
||||
"date_after/date_before filtering is not supported by Brave llm-context mode. Use Brave web mode for date filters.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
const dateAfter = rawDateAfter ? normalizeToIsoDate(rawDateAfter) : undefined;
|
||||
if (rawDateAfter && !dateAfter) {
|
||||
return {
|
||||
error: "invalid_date",
|
||||
message: "date_after must be YYYY-MM-DD format.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
const dateBefore = rawDateBefore ? normalizeToIsoDate(rawDateBefore) : undefined;
|
||||
if (rawDateBefore && !dateBefore) {
|
||||
return {
|
||||
error: "invalid_date",
|
||||
message: "date_before must be YYYY-MM-DD format.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
if (dateAfter && dateBefore && dateAfter > dateBefore) {
|
||||
return {
|
||||
error: "invalid_date_range",
|
||||
message: "date_after must be before date_before.",
|
||||
docs: "https://docs.openclaw.ai/tools/web",
|
||||
};
|
||||
}
|
||||
|
||||
const cacheKey = buildSearchCacheKey([
|
||||
"brave",
|
||||
braveMode,
|
||||
query,
|
||||
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
country,
|
||||
normalizedLanguage.search_lang,
|
||||
normalizedLanguage.ui_lang,
|
||||
freshness,
|
||||
dateAfter,
|
||||
dateBefore,
|
||||
]);
|
||||
const cached = readCachedSearchPayload(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const timeoutSeconds = resolveSearchTimeoutSeconds(searchConfig);
|
||||
const cacheTtlMs = resolveSearchCacheTtlMs(searchConfig);
|
||||
|
||||
if (braveMode === "llm-context") {
|
||||
const { results, sources } = await runBraveLlmContextSearch({
|
||||
query,
|
||||
apiKey,
|
||||
timeoutSeconds,
|
||||
country: country ?? undefined,
|
||||
search_lang: normalizedLanguage.search_lang,
|
||||
freshness,
|
||||
});
|
||||
const payload = {
|
||||
query,
|
||||
provider: "brave",
|
||||
mode: "llm-context" as const,
|
||||
count: results.length,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "brave",
|
||||
wrapped: true,
|
||||
},
|
||||
results: results.map((entry) => ({
|
||||
title: entry.title ? wrapWebContent(entry.title, "web_search") : "",
|
||||
url: entry.url,
|
||||
snippets: entry.snippets.map((snippet) => wrapWebContent(snippet, "web_search")),
|
||||
siteName: entry.siteName,
|
||||
})),
|
||||
sources,
|
||||
};
|
||||
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
|
||||
return payload;
|
||||
}
|
||||
|
||||
const results = await runBraveWebSearch({
|
||||
query,
|
||||
count: resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
|
||||
apiKey,
|
||||
timeoutSeconds,
|
||||
country: country ?? undefined,
|
||||
search_lang: normalizedLanguage.search_lang,
|
||||
ui_lang: normalizedLanguage.ui_lang,
|
||||
freshness,
|
||||
dateAfter,
|
||||
dateBefore,
|
||||
});
|
||||
const payload = {
|
||||
query,
|
||||
provider: "brave",
|
||||
count: results.length,
|
||||
tookMs: Date.now() - start,
|
||||
externalContent: {
|
||||
untrusted: true,
|
||||
source: "web_search",
|
||||
provider: "brave",
|
||||
wrapped: true,
|
||||
},
|
||||
results,
|
||||
};
|
||||
writeCachedSearchPayload(cacheKey, payload, cacheTtlMs);
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createBraveWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
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,
|
||||
credentialPath: "plugins.entries.brave.config.webSearch.apiKey",
|
||||
inactiveSecretPaths: ["plugins.entries.brave.config.webSearch.apiKey"],
|
||||
getCredentialValue: (searchConfig) => searchConfig?.apiKey,
|
||||
setCredentialValue: (searchConfigTarget, value) => {
|
||||
searchConfigTarget.apiKey = value;
|
||||
},
|
||||
getConfiguredCredentialValue: (config) =>
|
||||
resolveProviderWebSearchPluginConfig(config, "brave")?.apiKey,
|
||||
setConfiguredCredentialValue: (configTarget, value) => {
|
||||
setProviderWebSearchPluginConfigValue(configTarget, "brave", "apiKey", value);
|
||||
},
|
||||
createTool: (ctx) =>
|
||||
createBraveToolDefinition(ctx.config, ctx.searchConfig as SearchConfigRecord | undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
normalizeFreshness,
|
||||
normalizeBraveLanguageParams,
|
||||
resolveBraveMode,
|
||||
mapBraveLlmContextResults,
|
||||
} as const;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/chutes-provider",
|
||||
"version": "2026.3.17",
|
||||
"version": "2026.3.14",
|
||||
"private": true,
|
||||
"description": "OpenClaw Chutes.ai provider plugin",
|
||||
"type": "module",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"build:viewer": "bun build src/viewer-client.ts --target browser --format esm --minify --outfile assets/viewer-runtime.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pierre/diffs": "1.1.0",
|
||||
"@pierre/diffs": "1.1.1",
|
||||
"@sinclair/typebox": "0.34.48",
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from "./src/accounts.js";
|
||||
export * from "./src/actions/handle-action.guild-admin.js";
|
||||
export * from "./src/actions/handle-action.js";
|
||||
export * from "./src/components.js";
|
||||
export * from "./src/group-policy.js";
|
||||
export * from "./src/normalize.js";
|
||||
export * from "./src/pluralkit.js";
|
||||
export * from "./src/session-key-normalization.js";
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export * from "./src/audit.js";
|
||||
export * from "./src/actions/runtime.js";
|
||||
export * from "./src/actions/runtime.moderation-shared.js";
|
||||
export * from "./src/actions/runtime.shared.js";
|
||||
export * from "./src/channel-actions.js";
|
||||
export * from "./src/directory-live.js";
|
||||
export * from "./src/monitor.js";
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/discord";
|
||||
import {
|
||||
mergeDiscordAccountConfig,
|
||||
resolveDefaultDiscordAccountId,
|
||||
resolveDiscordAccountConfig,
|
||||
} from "./accounts.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeSecretInputString,
|
||||
type OpenClawConfig,
|
||||
type DiscordAccountConfig,
|
||||
} from "./runtime-api.js";
|
||||
|
||||
export type DiscordCredentialStatus = "available" | "configured_unavailable" | "missing";
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ import {
|
||||
normalizeAccountId,
|
||||
resolveAccountEntry,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
import type { DiscordAccountConfig, DiscordActionConfig } from "openclaw/plugin-sdk/discord";
|
||||
type DiscordAccountConfig,
|
||||
type DiscordActionConfig,
|
||||
} from "./runtime-api.js";
|
||||
import { resolveDiscordToken } from "./token.js";
|
||||
|
||||
export type ResolvedDiscordAccount = {
|
||||
|
||||
@@ -5,12 +5,12 @@ import {
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { handleDiscordAction } from "./runtime.js";
|
||||
import {
|
||||
isDiscordModerationAction,
|
||||
readDiscordModerationCommand,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { handleDiscordAction } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime";
|
||||
} from "./runtime.moderation-shared.js";
|
||||
|
||||
type Ctx = Pick<
|
||||
ChannelMessageActionContext,
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
} from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { readDiscordParentIdParam } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { handleDiscordAction } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
|
||||
import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime";
|
||||
@@ -13,6 +11,8 @@ import { normalizeInteractiveReply } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { buildDiscordInteractiveComponents } from "../shared-interactive.js";
|
||||
import { resolveDiscordChannelId } from "../targets.js";
|
||||
import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js";
|
||||
import { handleDiscordAction } from "./runtime.js";
|
||||
import { readDiscordParentIdParam } from "./runtime.shared.js";
|
||||
|
||||
const providerId = "discord";
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { DiscordActionConfig } from "../../config/config.js";
|
||||
import { getPresence } from "../monitor/presence-cache.js";
|
||||
import {
|
||||
type ActionGate,
|
||||
jsonResult,
|
||||
parseAvailableTags,
|
||||
readNumberParam,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
type DiscordActionConfig,
|
||||
} from "../runtime-api.js";
|
||||
import {
|
||||
addRoleDiscord,
|
||||
createChannelDiscord,
|
||||
@@ -19,17 +28,29 @@ import {
|
||||
setChannelPermissionDiscord,
|
||||
uploadEmojiDiscord,
|
||||
uploadStickerDiscord,
|
||||
} from "../../plugin-sdk/discord.js";
|
||||
import { getPresence } from "../../plugin-sdk/discord.js";
|
||||
import {
|
||||
type ActionGate,
|
||||
jsonResult,
|
||||
parseAvailableTags,
|
||||
readNumberParam,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
} from "./common.js";
|
||||
import { readDiscordParentIdParam } from "./discord-actions-shared.js";
|
||||
} from "../send.js";
|
||||
import { readDiscordParentIdParam } from "./runtime.shared.js";
|
||||
|
||||
export const discordGuildActionRuntime = {
|
||||
addRoleDiscord,
|
||||
createChannelDiscord,
|
||||
createScheduledEventDiscord,
|
||||
deleteChannelDiscord,
|
||||
editChannelDiscord,
|
||||
fetchChannelInfoDiscord,
|
||||
fetchMemberInfoDiscord,
|
||||
fetchRoleInfoDiscord,
|
||||
fetchVoiceStatusDiscord,
|
||||
listGuildChannelsDiscord,
|
||||
listGuildEmojisDiscord,
|
||||
listScheduledEventsDiscord,
|
||||
moveChannelDiscord,
|
||||
removeChannelPermissionDiscord,
|
||||
removeRoleDiscord,
|
||||
setChannelPermissionDiscord,
|
||||
uploadEmojiDiscord,
|
||||
uploadStickerDiscord,
|
||||
};
|
||||
|
||||
type DiscordRoleMutation = (params: {
|
||||
guildId: string;
|
||||
@@ -85,8 +106,8 @@ export async function handleDiscordGuildAction(
|
||||
required: true,
|
||||
});
|
||||
const member = accountId
|
||||
? await fetchMemberInfoDiscord(guildId, userId, { accountId })
|
||||
: await fetchMemberInfoDiscord(guildId, userId);
|
||||
? await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId, { accountId })
|
||||
: await discordGuildActionRuntime.fetchMemberInfoDiscord(guildId, userId);
|
||||
const presence = getPresence(accountId, userId);
|
||||
const activities = presence?.activities ?? undefined;
|
||||
const status = presence?.status ?? undefined;
|
||||
@@ -100,8 +121,8 @@ export async function handleDiscordGuildAction(
|
||||
required: true,
|
||||
});
|
||||
const roles = accountId
|
||||
? await fetchRoleInfoDiscord(guildId, { accountId })
|
||||
: await fetchRoleInfoDiscord(guildId);
|
||||
? await discordGuildActionRuntime.fetchRoleInfoDiscord(guildId, { accountId })
|
||||
: await discordGuildActionRuntime.fetchRoleInfoDiscord(guildId);
|
||||
return jsonResult({ ok: true, roles });
|
||||
}
|
||||
case "emojiList": {
|
||||
@@ -112,8 +133,8 @@ export async function handleDiscordGuildAction(
|
||||
required: true,
|
||||
});
|
||||
const emojis = accountId
|
||||
? await listGuildEmojisDiscord(guildId, { accountId })
|
||||
: await listGuildEmojisDiscord(guildId);
|
||||
? await discordGuildActionRuntime.listGuildEmojisDiscord(guildId, { accountId })
|
||||
: await discordGuildActionRuntime.listGuildEmojisDiscord(guildId);
|
||||
return jsonResult({ ok: true, emojis });
|
||||
}
|
||||
case "emojiUpload": {
|
||||
@@ -129,7 +150,7 @@ export async function handleDiscordGuildAction(
|
||||
});
|
||||
const roleIds = readStringArrayParam(params, "roleIds");
|
||||
const emoji = accountId
|
||||
? await uploadEmojiDiscord(
|
||||
? await discordGuildActionRuntime.uploadEmojiDiscord(
|
||||
{
|
||||
guildId,
|
||||
name,
|
||||
@@ -138,7 +159,7 @@ export async function handleDiscordGuildAction(
|
||||
},
|
||||
{ accountId },
|
||||
)
|
||||
: await uploadEmojiDiscord({
|
||||
: await discordGuildActionRuntime.uploadEmojiDiscord({
|
||||
guildId,
|
||||
name,
|
||||
mediaUrl,
|
||||
@@ -162,7 +183,7 @@ export async function handleDiscordGuildAction(
|
||||
required: true,
|
||||
});
|
||||
const sticker = accountId
|
||||
? await uploadStickerDiscord(
|
||||
? await discordGuildActionRuntime.uploadStickerDiscord(
|
||||
{
|
||||
guildId,
|
||||
name,
|
||||
@@ -172,7 +193,7 @@ export async function handleDiscordGuildAction(
|
||||
},
|
||||
{ accountId },
|
||||
)
|
||||
: await uploadStickerDiscord({
|
||||
: await discordGuildActionRuntime.uploadStickerDiscord({
|
||||
guildId,
|
||||
name,
|
||||
description,
|
||||
@@ -185,14 +206,22 @@ export async function handleDiscordGuildAction(
|
||||
if (!isActionEnabled("roles", false)) {
|
||||
throw new Error("Discord role changes are disabled.");
|
||||
}
|
||||
await runRoleMutation({ accountId, values: params, mutate: addRoleDiscord });
|
||||
await runRoleMutation({
|
||||
accountId,
|
||||
values: params,
|
||||
mutate: discordGuildActionRuntime.addRoleDiscord,
|
||||
});
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "roleRemove": {
|
||||
if (!isActionEnabled("roles", false)) {
|
||||
throw new Error("Discord role changes are disabled.");
|
||||
}
|
||||
await runRoleMutation({ accountId, values: params, mutate: removeRoleDiscord });
|
||||
await runRoleMutation({
|
||||
accountId,
|
||||
values: params,
|
||||
mutate: discordGuildActionRuntime.removeRoleDiscord,
|
||||
});
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "channelInfo": {
|
||||
@@ -203,8 +232,8 @@ export async function handleDiscordGuildAction(
|
||||
required: true,
|
||||
});
|
||||
const channel = accountId
|
||||
? await fetchChannelInfoDiscord(channelId, { accountId })
|
||||
: await fetchChannelInfoDiscord(channelId);
|
||||
? await discordGuildActionRuntime.fetchChannelInfoDiscord(channelId, { accountId })
|
||||
: await discordGuildActionRuntime.fetchChannelInfoDiscord(channelId);
|
||||
return jsonResult({ ok: true, channel });
|
||||
}
|
||||
case "channelList": {
|
||||
@@ -215,8 +244,8 @@ export async function handleDiscordGuildAction(
|
||||
required: true,
|
||||
});
|
||||
const channels = accountId
|
||||
? await listGuildChannelsDiscord(guildId, { accountId })
|
||||
: await listGuildChannelsDiscord(guildId);
|
||||
? await discordGuildActionRuntime.listGuildChannelsDiscord(guildId, { accountId })
|
||||
: await discordGuildActionRuntime.listGuildChannelsDiscord(guildId);
|
||||
return jsonResult({ ok: true, channels });
|
||||
}
|
||||
case "voiceStatus": {
|
||||
@@ -230,8 +259,10 @@ export async function handleDiscordGuildAction(
|
||||
required: true,
|
||||
});
|
||||
const voice = accountId
|
||||
? await fetchVoiceStatusDiscord(guildId, userId, { accountId })
|
||||
: await fetchVoiceStatusDiscord(guildId, userId);
|
||||
? await discordGuildActionRuntime.fetchVoiceStatusDiscord(guildId, userId, {
|
||||
accountId,
|
||||
})
|
||||
: await discordGuildActionRuntime.fetchVoiceStatusDiscord(guildId, userId);
|
||||
return jsonResult({ ok: true, voice });
|
||||
}
|
||||
case "eventList": {
|
||||
@@ -242,8 +273,8 @@ export async function handleDiscordGuildAction(
|
||||
required: true,
|
||||
});
|
||||
const events = accountId
|
||||
? await listScheduledEventsDiscord(guildId, { accountId })
|
||||
: await listScheduledEventsDiscord(guildId);
|
||||
? await discordGuildActionRuntime.listScheduledEventsDiscord(guildId, { accountId })
|
||||
: await discordGuildActionRuntime.listScheduledEventsDiscord(guildId);
|
||||
return jsonResult({ ok: true, events });
|
||||
}
|
||||
case "eventCreate": {
|
||||
@@ -274,8 +305,10 @@ export async function handleDiscordGuildAction(
|
||||
privacy_level: 2,
|
||||
};
|
||||
const event = accountId
|
||||
? await createScheduledEventDiscord(guildId, payload, { accountId })
|
||||
: await createScheduledEventDiscord(guildId, payload);
|
||||
? await discordGuildActionRuntime.createScheduledEventDiscord(guildId, payload, {
|
||||
accountId,
|
||||
})
|
||||
: await discordGuildActionRuntime.createScheduledEventDiscord(guildId, payload);
|
||||
return jsonResult({ ok: true, event });
|
||||
}
|
||||
case "channelCreate": {
|
||||
@@ -290,7 +323,7 @@ export async function handleDiscordGuildAction(
|
||||
const position = readNumberParam(params, "position", { integer: true });
|
||||
const nsfw = params.nsfw as boolean | undefined;
|
||||
const channel = accountId
|
||||
? await createChannelDiscord(
|
||||
? await discordGuildActionRuntime.createChannelDiscord(
|
||||
{
|
||||
guildId,
|
||||
name,
|
||||
@@ -302,7 +335,7 @@ export async function handleDiscordGuildAction(
|
||||
},
|
||||
{ accountId },
|
||||
)
|
||||
: await createChannelDiscord({
|
||||
: await discordGuildActionRuntime.createChannelDiscord({
|
||||
guildId,
|
||||
name,
|
||||
type: type ?? undefined,
|
||||
@@ -348,8 +381,8 @@ export async function handleDiscordGuildAction(
|
||||
availableTags,
|
||||
};
|
||||
const channel = accountId
|
||||
? await editChannelDiscord(editPayload, { accountId })
|
||||
: await editChannelDiscord(editPayload);
|
||||
? await discordGuildActionRuntime.editChannelDiscord(editPayload, { accountId })
|
||||
: await discordGuildActionRuntime.editChannelDiscord(editPayload);
|
||||
return jsonResult({ ok: true, channel });
|
||||
}
|
||||
case "channelDelete": {
|
||||
@@ -360,8 +393,8 @@ export async function handleDiscordGuildAction(
|
||||
required: true,
|
||||
});
|
||||
const result = accountId
|
||||
? await deleteChannelDiscord(channelId, { accountId })
|
||||
: await deleteChannelDiscord(channelId);
|
||||
? await discordGuildActionRuntime.deleteChannelDiscord(channelId, { accountId })
|
||||
: await discordGuildActionRuntime.deleteChannelDiscord(channelId);
|
||||
return jsonResult(result);
|
||||
}
|
||||
case "channelMove": {
|
||||
@@ -375,7 +408,7 @@ export async function handleDiscordGuildAction(
|
||||
const parentId = readDiscordParentIdParam(params);
|
||||
const position = readNumberParam(params, "position", { integer: true });
|
||||
if (accountId) {
|
||||
await moveChannelDiscord(
|
||||
await discordGuildActionRuntime.moveChannelDiscord(
|
||||
{
|
||||
guildId,
|
||||
channelId,
|
||||
@@ -385,7 +418,7 @@ export async function handleDiscordGuildAction(
|
||||
{ accountId },
|
||||
);
|
||||
} else {
|
||||
await moveChannelDiscord({
|
||||
await discordGuildActionRuntime.moveChannelDiscord({
|
||||
guildId,
|
||||
channelId,
|
||||
parentId,
|
||||
@@ -402,7 +435,7 @@ export async function handleDiscordGuildAction(
|
||||
const name = readStringParam(params, "name", { required: true });
|
||||
const position = readNumberParam(params, "position", { integer: true });
|
||||
const channel = accountId
|
||||
? await createChannelDiscord(
|
||||
? await discordGuildActionRuntime.createChannelDiscord(
|
||||
{
|
||||
guildId,
|
||||
name,
|
||||
@@ -411,7 +444,7 @@ export async function handleDiscordGuildAction(
|
||||
},
|
||||
{ accountId },
|
||||
)
|
||||
: await createChannelDiscord({
|
||||
: await discordGuildActionRuntime.createChannelDiscord({
|
||||
guildId,
|
||||
name,
|
||||
type: 4,
|
||||
@@ -429,7 +462,7 @@ export async function handleDiscordGuildAction(
|
||||
const name = readStringParam(params, "name");
|
||||
const position = readNumberParam(params, "position", { integer: true });
|
||||
const channel = accountId
|
||||
? await editChannelDiscord(
|
||||
? await discordGuildActionRuntime.editChannelDiscord(
|
||||
{
|
||||
channelId: categoryId,
|
||||
name: name ?? undefined,
|
||||
@@ -437,7 +470,7 @@ export async function handleDiscordGuildAction(
|
||||
},
|
||||
{ accountId },
|
||||
)
|
||||
: await editChannelDiscord({
|
||||
: await discordGuildActionRuntime.editChannelDiscord({
|
||||
channelId: categoryId,
|
||||
name: name ?? undefined,
|
||||
position: position ?? undefined,
|
||||
@@ -452,8 +485,8 @@ export async function handleDiscordGuildAction(
|
||||
required: true,
|
||||
});
|
||||
const result = accountId
|
||||
? await deleteChannelDiscord(categoryId, { accountId })
|
||||
: await deleteChannelDiscord(categoryId);
|
||||
? await discordGuildActionRuntime.deleteChannelDiscord(categoryId, { accountId })
|
||||
: await discordGuildActionRuntime.deleteChannelDiscord(categoryId);
|
||||
return jsonResult(result);
|
||||
}
|
||||
case "channelPermissionSet": {
|
||||
@@ -468,7 +501,7 @@ export async function handleDiscordGuildAction(
|
||||
const allow = readStringParam(params, "allow");
|
||||
const deny = readStringParam(params, "deny");
|
||||
if (accountId) {
|
||||
await setChannelPermissionDiscord(
|
||||
await discordGuildActionRuntime.setChannelPermissionDiscord(
|
||||
{
|
||||
channelId,
|
||||
targetId,
|
||||
@@ -479,7 +512,7 @@ export async function handleDiscordGuildAction(
|
||||
{ accountId },
|
||||
);
|
||||
} else {
|
||||
await setChannelPermissionDiscord({
|
||||
await discordGuildActionRuntime.setChannelPermissionDiscord({
|
||||
channelId,
|
||||
targetId,
|
||||
targetType,
|
||||
@@ -495,9 +528,11 @@ export async function handleDiscordGuildAction(
|
||||
}
|
||||
const { channelId, targetId } = readChannelPermissionTarget(params);
|
||||
if (accountId) {
|
||||
await removeChannelPermissionDiscord(channelId, targetId, { accountId });
|
||||
await discordGuildActionRuntime.removeChannelPermissionDiscord(channelId, targetId, {
|
||||
accountId,
|
||||
});
|
||||
} else {
|
||||
await removeChannelPermissionDiscord(channelId, targetId);
|
||||
await discordGuildActionRuntime.removeChannelPermissionDiscord(channelId, targetId);
|
||||
}
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
@@ -1,7 +1,19 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { DiscordActionConfig } from "../../config/config.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { readBooleanParam } from "../../plugin-sdk/boolean-param.js";
|
||||
import { readDiscordComponentSpec } from "../components.js";
|
||||
import {
|
||||
assertMediaNotDataUrl,
|
||||
type ActionGate,
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
resolvePollMaxSelections,
|
||||
type DiscordActionConfig,
|
||||
type OpenClawConfig,
|
||||
withNormalizedTimestamp,
|
||||
readBooleanParam,
|
||||
} from "../runtime-api.js";
|
||||
import {
|
||||
createThreadDiscord,
|
||||
deleteMessageDiscord,
|
||||
@@ -23,20 +35,34 @@ import {
|
||||
sendStickerDiscord,
|
||||
sendVoiceMessageDiscord,
|
||||
unpinMessageDiscord,
|
||||
} from "../../plugin-sdk/discord.js";
|
||||
import type { DiscordSendComponents, DiscordSendEmbeds } from "../../plugin-sdk/discord.js";
|
||||
import { readDiscordComponentSpec, resolveDiscordChannelId } from "../../plugin-sdk/discord.js";
|
||||
import { resolvePollMaxSelections } from "../../polls.js";
|
||||
import { withNormalizedTimestamp } from "../date-time.js";
|
||||
import { assertMediaNotDataUrl } from "../sandbox-paths.js";
|
||||
import {
|
||||
type ActionGate,
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readReactionParams,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
} from "./common.js";
|
||||
} from "../send.js";
|
||||
import type { DiscordSendComponents, DiscordSendEmbeds } from "../send.shared.js";
|
||||
import { resolveDiscordChannelId } from "../targets.js";
|
||||
|
||||
export const discordMessagingActionRuntime = {
|
||||
createThreadDiscord,
|
||||
deleteMessageDiscord,
|
||||
editMessageDiscord,
|
||||
fetchChannelPermissionsDiscord,
|
||||
fetchMessageDiscord,
|
||||
fetchReactionsDiscord,
|
||||
listPinsDiscord,
|
||||
listThreadsDiscord,
|
||||
pinMessageDiscord,
|
||||
reactMessageDiscord,
|
||||
readDiscordComponentSpec,
|
||||
readMessagesDiscord,
|
||||
removeOwnReactionsDiscord,
|
||||
removeReactionDiscord,
|
||||
resolveDiscordChannelId,
|
||||
searchMessagesDiscord,
|
||||
sendDiscordComponentMessage,
|
||||
sendMessageDiscord,
|
||||
sendPollDiscord,
|
||||
sendStickerDiscord,
|
||||
sendVoiceMessageDiscord,
|
||||
unpinMessageDiscord,
|
||||
};
|
||||
|
||||
function parseDiscordMessageLink(link: string) {
|
||||
const normalized = link.trim();
|
||||
@@ -65,7 +91,7 @@ export async function handleDiscordMessagingAction(
|
||||
cfg?: OpenClawConfig,
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
const resolveChannelId = () =>
|
||||
resolveDiscordChannelId(
|
||||
discordMessagingActionRuntime.resolveDiscordChannelId(
|
||||
readStringParam(params, "channelId", {
|
||||
required: true,
|
||||
}),
|
||||
@@ -95,28 +121,45 @@ export async function handleDiscordMessagingAction(
|
||||
});
|
||||
if (remove) {
|
||||
if (accountId) {
|
||||
await removeReactionDiscord(channelId, messageId, emoji, {
|
||||
await discordMessagingActionRuntime.removeReactionDiscord(channelId, messageId, emoji, {
|
||||
...cfgOptions,
|
||||
accountId,
|
||||
});
|
||||
} else {
|
||||
await removeReactionDiscord(channelId, messageId, emoji, cfgOptions);
|
||||
await discordMessagingActionRuntime.removeReactionDiscord(
|
||||
channelId,
|
||||
messageId,
|
||||
emoji,
|
||||
cfgOptions,
|
||||
);
|
||||
}
|
||||
return jsonResult({ ok: true, removed: emoji });
|
||||
}
|
||||
if (isEmpty) {
|
||||
const removed = accountId
|
||||
? await removeOwnReactionsDiscord(channelId, messageId, { ...cfgOptions, accountId })
|
||||
: await removeOwnReactionsDiscord(channelId, messageId, cfgOptions);
|
||||
? await discordMessagingActionRuntime.removeOwnReactionsDiscord(channelId, messageId, {
|
||||
...cfgOptions,
|
||||
accountId,
|
||||
})
|
||||
: await discordMessagingActionRuntime.removeOwnReactionsDiscord(
|
||||
channelId,
|
||||
messageId,
|
||||
cfgOptions,
|
||||
);
|
||||
return jsonResult({ ok: true, removed: removed.removed });
|
||||
}
|
||||
if (accountId) {
|
||||
await reactMessageDiscord(channelId, messageId, emoji, {
|
||||
await discordMessagingActionRuntime.reactMessageDiscord(channelId, messageId, emoji, {
|
||||
...cfgOptions,
|
||||
accountId,
|
||||
});
|
||||
} else {
|
||||
await reactMessageDiscord(channelId, messageId, emoji, cfgOptions);
|
||||
await discordMessagingActionRuntime.reactMessageDiscord(
|
||||
channelId,
|
||||
messageId,
|
||||
emoji,
|
||||
cfgOptions,
|
||||
);
|
||||
}
|
||||
return jsonResult({ ok: true, added: emoji });
|
||||
}
|
||||
@@ -129,11 +172,15 @@ export async function handleDiscordMessagingAction(
|
||||
required: true,
|
||||
});
|
||||
const limit = readNumberParam(params, "limit");
|
||||
const reactions = await fetchReactionsDiscord(channelId, messageId, {
|
||||
...cfgOptions,
|
||||
...(accountId ? { accountId } : {}),
|
||||
limit,
|
||||
});
|
||||
const reactions = await discordMessagingActionRuntime.fetchReactionsDiscord(
|
||||
channelId,
|
||||
messageId,
|
||||
{
|
||||
...cfgOptions,
|
||||
...(accountId ? { accountId } : {}),
|
||||
limit,
|
||||
},
|
||||
);
|
||||
return jsonResult({ ok: true, reactions });
|
||||
}
|
||||
case "sticker": {
|
||||
@@ -146,7 +193,7 @@ export async function handleDiscordMessagingAction(
|
||||
required: true,
|
||||
label: "stickerIds",
|
||||
});
|
||||
await sendStickerDiscord(to, stickerIds, {
|
||||
await discordMessagingActionRuntime.sendStickerDiscord(to, stickerIds, {
|
||||
...cfgOptions,
|
||||
...(accountId ? { accountId } : {}),
|
||||
content,
|
||||
@@ -169,7 +216,7 @@ export async function handleDiscordMessagingAction(
|
||||
const allowMultiselect = readBooleanParam(params, "allowMultiselect");
|
||||
const durationHours = readNumberParam(params, "durationHours");
|
||||
const maxSelections = resolvePollMaxSelections(answers.length, allowMultiselect);
|
||||
await sendPollDiscord(
|
||||
await discordMessagingActionRuntime.sendPollDiscord(
|
||||
to,
|
||||
{ question, options: answers, maxSelections, durationHours },
|
||||
{ ...cfgOptions, ...(accountId ? { accountId } : {}), content },
|
||||
@@ -182,8 +229,11 @@ export async function handleDiscordMessagingAction(
|
||||
}
|
||||
const channelId = resolveChannelId();
|
||||
const permissions = accountId
|
||||
? await fetchChannelPermissionsDiscord(channelId, { ...cfgOptions, accountId })
|
||||
: await fetchChannelPermissionsDiscord(channelId, cfgOptions);
|
||||
? await discordMessagingActionRuntime.fetchChannelPermissionsDiscord(channelId, {
|
||||
...cfgOptions,
|
||||
accountId,
|
||||
})
|
||||
: await discordMessagingActionRuntime.fetchChannelPermissionsDiscord(channelId, cfgOptions);
|
||||
return jsonResult({ ok: true, permissions });
|
||||
}
|
||||
case "fetchMessage": {
|
||||
@@ -206,8 +256,11 @@ export async function handleDiscordMessagingAction(
|
||||
);
|
||||
}
|
||||
const message = accountId
|
||||
? await fetchMessageDiscord(channelId, messageId, { ...cfgOptions, accountId })
|
||||
: await fetchMessageDiscord(channelId, messageId, cfgOptions);
|
||||
? await discordMessagingActionRuntime.fetchMessageDiscord(channelId, messageId, {
|
||||
...cfgOptions,
|
||||
accountId,
|
||||
})
|
||||
: await discordMessagingActionRuntime.fetchMessageDiscord(channelId, messageId, cfgOptions);
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
message: normalizeMessage(message),
|
||||
@@ -228,8 +281,11 @@ export async function handleDiscordMessagingAction(
|
||||
around: readStringParam(params, "around"),
|
||||
};
|
||||
const messages = accountId
|
||||
? await readMessagesDiscord(channelId, query, { ...cfgOptions, accountId })
|
||||
: await readMessagesDiscord(channelId, query, cfgOptions);
|
||||
? await discordMessagingActionRuntime.readMessagesDiscord(channelId, query, {
|
||||
...cfgOptions,
|
||||
accountId,
|
||||
})
|
||||
: await discordMessagingActionRuntime.readMessagesDiscord(channelId, query, cfgOptions);
|
||||
return jsonResult({
|
||||
ok: true,
|
||||
messages: messages.map((message) => normalizeMessage(message)),
|
||||
@@ -245,7 +301,7 @@ export async function handleDiscordMessagingAction(
|
||||
const rawComponents = params.components;
|
||||
const componentSpec =
|
||||
rawComponents && typeof rawComponents === "object" && !Array.isArray(rawComponents)
|
||||
? readDiscordComponentSpec(rawComponents)
|
||||
? discordMessagingActionRuntime.readDiscordComponentSpec(rawComponents)
|
||||
: null;
|
||||
const components: DiscordSendComponents | undefined =
|
||||
Array.isArray(rawComponents) || typeof rawComponents === "function"
|
||||
@@ -279,16 +335,20 @@ export async function handleDiscordMessagingAction(
|
||||
const payload = componentSpec.text
|
||||
? componentSpec
|
||||
: { ...componentSpec, text: normalizedContent };
|
||||
const result = await sendDiscordComponentMessage(to, payload, {
|
||||
...cfgOptions,
|
||||
...(accountId ? { accountId } : {}),
|
||||
silent,
|
||||
replyTo: replyTo ?? undefined,
|
||||
sessionKey: sessionKey ?? undefined,
|
||||
agentId: agentId ?? undefined,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
filename: filename ?? undefined,
|
||||
});
|
||||
const result = await discordMessagingActionRuntime.sendDiscordComponentMessage(
|
||||
to,
|
||||
payload,
|
||||
{
|
||||
...cfgOptions,
|
||||
...(accountId ? { accountId } : {}),
|
||||
silent,
|
||||
replyTo: replyTo ?? undefined,
|
||||
sessionKey: sessionKey ?? undefined,
|
||||
agentId: agentId ?? undefined,
|
||||
mediaUrl: mediaUrl ?? undefined,
|
||||
filename: filename ?? undefined,
|
||||
},
|
||||
);
|
||||
return jsonResult({ ok: true, result, components: true });
|
||||
}
|
||||
|
||||
@@ -305,7 +365,7 @@ export async function handleDiscordMessagingAction(
|
||||
);
|
||||
}
|
||||
assertMediaNotDataUrl(mediaUrl);
|
||||
const result = await sendVoiceMessageDiscord(to, mediaUrl, {
|
||||
const result = await discordMessagingActionRuntime.sendVoiceMessageDiscord(to, mediaUrl, {
|
||||
...cfgOptions,
|
||||
...(accountId ? { accountId } : {}),
|
||||
replyTo,
|
||||
@@ -314,7 +374,7 @@ export async function handleDiscordMessagingAction(
|
||||
return jsonResult({ ok: true, result, voiceMessage: true });
|
||||
}
|
||||
|
||||
const result = await sendMessageDiscord(to, content ?? "", {
|
||||
const result = await discordMessagingActionRuntime.sendMessageDiscord(to, content ?? "", {
|
||||
...cfgOptions,
|
||||
...(accountId ? { accountId } : {}),
|
||||
mediaUrl,
|
||||
@@ -338,8 +398,18 @@ export async function handleDiscordMessagingAction(
|
||||
required: true,
|
||||
});
|
||||
const message = accountId
|
||||
? await editMessageDiscord(channelId, messageId, { content }, { ...cfgOptions, accountId })
|
||||
: await editMessageDiscord(channelId, messageId, { content }, cfgOptions);
|
||||
? await discordMessagingActionRuntime.editMessageDiscord(
|
||||
channelId,
|
||||
messageId,
|
||||
{ content },
|
||||
{ ...cfgOptions, accountId },
|
||||
)
|
||||
: await discordMessagingActionRuntime.editMessageDiscord(
|
||||
channelId,
|
||||
messageId,
|
||||
{ content },
|
||||
cfgOptions,
|
||||
);
|
||||
return jsonResult({ ok: true, message });
|
||||
}
|
||||
case "deleteMessage": {
|
||||
@@ -351,9 +421,12 @@ export async function handleDiscordMessagingAction(
|
||||
required: true,
|
||||
});
|
||||
if (accountId) {
|
||||
await deleteMessageDiscord(channelId, messageId, { ...cfgOptions, accountId });
|
||||
await discordMessagingActionRuntime.deleteMessageDiscord(channelId, messageId, {
|
||||
...cfgOptions,
|
||||
accountId,
|
||||
});
|
||||
} else {
|
||||
await deleteMessageDiscord(channelId, messageId, cfgOptions);
|
||||
await discordMessagingActionRuntime.deleteMessageDiscord(channelId, messageId, cfgOptions);
|
||||
}
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
@@ -375,8 +448,11 @@ export async function handleDiscordMessagingAction(
|
||||
appliedTags: appliedTags ?? undefined,
|
||||
};
|
||||
const thread = accountId
|
||||
? await createThreadDiscord(channelId, payload, { ...cfgOptions, accountId })
|
||||
: await createThreadDiscord(channelId, payload, cfgOptions);
|
||||
? await discordMessagingActionRuntime.createThreadDiscord(channelId, payload, {
|
||||
...cfgOptions,
|
||||
accountId,
|
||||
})
|
||||
: await discordMessagingActionRuntime.createThreadDiscord(channelId, payload, cfgOptions);
|
||||
return jsonResult({ ok: true, thread });
|
||||
}
|
||||
case "threadList": {
|
||||
@@ -391,7 +467,7 @@ export async function handleDiscordMessagingAction(
|
||||
const before = readStringParam(params, "before");
|
||||
const limit = readNumberParam(params, "limit");
|
||||
const threads = accountId
|
||||
? await listThreadsDiscord(
|
||||
? await discordMessagingActionRuntime.listThreadsDiscord(
|
||||
{
|
||||
guildId,
|
||||
channelId,
|
||||
@@ -401,7 +477,7 @@ export async function handleDiscordMessagingAction(
|
||||
},
|
||||
{ ...cfgOptions, accountId },
|
||||
)
|
||||
: await listThreadsDiscord(
|
||||
: await discordMessagingActionRuntime.listThreadsDiscord(
|
||||
{
|
||||
guildId,
|
||||
channelId,
|
||||
@@ -423,13 +499,17 @@ export async function handleDiscordMessagingAction(
|
||||
});
|
||||
const mediaUrl = readStringParam(params, "mediaUrl");
|
||||
const replyTo = readStringParam(params, "replyTo");
|
||||
const result = await sendMessageDiscord(`channel:${channelId}`, content, {
|
||||
...cfgOptions,
|
||||
...(accountId ? { accountId } : {}),
|
||||
mediaUrl,
|
||||
mediaLocalRoots: options?.mediaLocalRoots,
|
||||
replyTo,
|
||||
});
|
||||
const result = await discordMessagingActionRuntime.sendMessageDiscord(
|
||||
`channel:${channelId}`,
|
||||
content,
|
||||
{
|
||||
...cfgOptions,
|
||||
...(accountId ? { accountId } : {}),
|
||||
mediaUrl,
|
||||
mediaLocalRoots: options?.mediaLocalRoots,
|
||||
replyTo,
|
||||
},
|
||||
);
|
||||
return jsonResult({ ok: true, result });
|
||||
}
|
||||
case "pinMessage": {
|
||||
@@ -441,9 +521,12 @@ export async function handleDiscordMessagingAction(
|
||||
required: true,
|
||||
});
|
||||
if (accountId) {
|
||||
await pinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId });
|
||||
await discordMessagingActionRuntime.pinMessageDiscord(channelId, messageId, {
|
||||
...cfgOptions,
|
||||
accountId,
|
||||
});
|
||||
} else {
|
||||
await pinMessageDiscord(channelId, messageId, cfgOptions);
|
||||
await discordMessagingActionRuntime.pinMessageDiscord(channelId, messageId, cfgOptions);
|
||||
}
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
@@ -456,9 +539,12 @@ export async function handleDiscordMessagingAction(
|
||||
required: true,
|
||||
});
|
||||
if (accountId) {
|
||||
await unpinMessageDiscord(channelId, messageId, { ...cfgOptions, accountId });
|
||||
await discordMessagingActionRuntime.unpinMessageDiscord(channelId, messageId, {
|
||||
...cfgOptions,
|
||||
accountId,
|
||||
});
|
||||
} else {
|
||||
await unpinMessageDiscord(channelId, messageId, cfgOptions);
|
||||
await discordMessagingActionRuntime.unpinMessageDiscord(channelId, messageId, cfgOptions);
|
||||
}
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
@@ -468,8 +554,11 @@ export async function handleDiscordMessagingAction(
|
||||
}
|
||||
const channelId = resolveChannelId();
|
||||
const pins = accountId
|
||||
? await listPinsDiscord(channelId, { ...cfgOptions, accountId })
|
||||
: await listPinsDiscord(channelId, cfgOptions);
|
||||
? await discordMessagingActionRuntime.listPinsDiscord(channelId, {
|
||||
...cfgOptions,
|
||||
accountId,
|
||||
})
|
||||
: await discordMessagingActionRuntime.listPinsDiscord(channelId, cfgOptions);
|
||||
return jsonResult({ ok: true, pins: pins.map((pin) => normalizeMessage(pin)) });
|
||||
}
|
||||
case "searchMessages": {
|
||||
@@ -490,7 +579,7 @@ export async function handleDiscordMessagingAction(
|
||||
const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])];
|
||||
const authorIdList = [...(authorIds ?? []), ...(authorId ? [authorId] : [])];
|
||||
const results = accountId
|
||||
? await searchMessagesDiscord(
|
||||
? await discordMessagingActionRuntime.searchMessagesDiscord(
|
||||
{
|
||||
guildId,
|
||||
content,
|
||||
@@ -500,7 +589,7 @@ export async function handleDiscordMessagingAction(
|
||||
},
|
||||
{ ...cfgOptions, accountId },
|
||||
)
|
||||
: await searchMessagesDiscord(
|
||||
: await discordMessagingActionRuntime.searchMessagesDiscord(
|
||||
{
|
||||
guildId,
|
||||
content,
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PermissionFlagsBits } from "discord-api-types/v10";
|
||||
import { readNumberParam, readStringParam } from "./common.js";
|
||||
import { readNumberParam, readStringParam } from "../runtime-api.js";
|
||||
|
||||
export type DiscordModerationAction = "timeout" | "kick" | "ban";
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { PermissionFlagsBits } from "discord-api-types/v10";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { DiscordActionConfig } from "../../config/config.js";
|
||||
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DiscordActionConfig } from "../../../../src/config/types.discord.js";
|
||||
import {
|
||||
discordModerationActionRuntime,
|
||||
handleDiscordModerationAction,
|
||||
} from "./runtime.moderation.js";
|
||||
|
||||
const discordSendMocks = vi.hoisted(() => ({
|
||||
banMemberDiscord: vi.fn(async () => ({ ok: true })),
|
||||
kickMemberDiscord: vi.fn(async () => ({ ok: true })),
|
||||
timeoutMemberDiscord: vi.fn(async () => ({ id: "user-1" })),
|
||||
hasAnyGuildPermissionDiscord: vi.fn(async () => false),
|
||||
}));
|
||||
|
||||
const { banMemberDiscord, kickMemberDiscord, timeoutMemberDiscord, hasAnyGuildPermissionDiscord } =
|
||||
discordSendMocks;
|
||||
|
||||
vi.mock("../../../extensions/discord/src/send.js", () => ({
|
||||
...discordSendMocks,
|
||||
}));
|
||||
const originalDiscordModerationActionRuntime = { ...discordModerationActionRuntime };
|
||||
const banMemberDiscord = vi.fn(async () => ({ ok: true }));
|
||||
const kickMemberDiscord = vi.fn(async () => ({ ok: true }));
|
||||
const timeoutMemberDiscord = vi.fn(async () => ({ id: "user-1" }));
|
||||
const hasAnyGuildPermissionDiscord = vi.fn(async () => false);
|
||||
|
||||
const enableAllActions = (_key: keyof DiscordActionConfig, _defaultValue = true) => true;
|
||||
|
||||
describe("discord moderation sender authorization", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.assign(discordModerationActionRuntime, originalDiscordModerationActionRuntime, {
|
||||
banMemberDiscord,
|
||||
kickMemberDiscord,
|
||||
timeoutMemberDiscord,
|
||||
hasAnyGuildPermissionDiscord,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects ban when sender lacks BAN_MEMBERS", async () => {
|
||||
hasAnyGuildPermissionDiscord.mockResolvedValueOnce(false);
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { DiscordActionConfig } from "../../config/config.js";
|
||||
import {
|
||||
type ActionGate,
|
||||
jsonResult,
|
||||
readStringParam,
|
||||
type DiscordActionConfig,
|
||||
} from "../runtime-api.js";
|
||||
import {
|
||||
banMemberDiscord,
|
||||
hasAnyGuildPermissionDiscord,
|
||||
kickMemberDiscord,
|
||||
timeoutMemberDiscord,
|
||||
} from "../../plugin-sdk/discord.js";
|
||||
import { type ActionGate, jsonResult, readStringParam } from "./common.js";
|
||||
} from "../send.js";
|
||||
import {
|
||||
isDiscordModerationAction,
|
||||
readDiscordModerationCommand,
|
||||
requiredGuildPermissionForModerationAction,
|
||||
} from "./discord-actions-moderation-shared.js";
|
||||
} from "./runtime.moderation-shared.js";
|
||||
|
||||
export const discordModerationActionRuntime = {
|
||||
banMemberDiscord,
|
||||
hasAnyGuildPermissionDiscord,
|
||||
kickMemberDiscord,
|
||||
timeoutMemberDiscord,
|
||||
};
|
||||
|
||||
async function verifySenderModerationPermission(params: {
|
||||
guildId: string;
|
||||
@@ -23,7 +34,7 @@ async function verifySenderModerationPermission(params: {
|
||||
if (!params.senderUserId) {
|
||||
return;
|
||||
}
|
||||
const hasPermission = await hasAnyGuildPermissionDiscord(
|
||||
const hasPermission = await discordModerationActionRuntime.hasAnyGuildPermissionDiscord(
|
||||
params.guildId,
|
||||
params.senderUserId,
|
||||
[params.requiredPermission],
|
||||
@@ -57,7 +68,7 @@ export async function handleDiscordModerationAction(
|
||||
switch (command.action) {
|
||||
case "timeout": {
|
||||
const member = accountId
|
||||
? await timeoutMemberDiscord(
|
||||
? await discordModerationActionRuntime.timeoutMemberDiscord(
|
||||
{
|
||||
guildId: command.guildId,
|
||||
userId: command.userId,
|
||||
@@ -67,7 +78,7 @@ export async function handleDiscordModerationAction(
|
||||
},
|
||||
{ accountId },
|
||||
)
|
||||
: await timeoutMemberDiscord({
|
||||
: await discordModerationActionRuntime.timeoutMemberDiscord({
|
||||
guildId: command.guildId,
|
||||
userId: command.userId,
|
||||
durationMinutes: command.durationMinutes,
|
||||
@@ -78,7 +89,7 @@ export async function handleDiscordModerationAction(
|
||||
}
|
||||
case "kick": {
|
||||
if (accountId) {
|
||||
await kickMemberDiscord(
|
||||
await discordModerationActionRuntime.kickMemberDiscord(
|
||||
{
|
||||
guildId: command.guildId,
|
||||
userId: command.userId,
|
||||
@@ -87,7 +98,7 @@ export async function handleDiscordModerationAction(
|
||||
{ accountId },
|
||||
);
|
||||
} else {
|
||||
await kickMemberDiscord({
|
||||
await discordModerationActionRuntime.kickMemberDiscord({
|
||||
guildId: command.guildId,
|
||||
userId: command.userId,
|
||||
reason: command.reason,
|
||||
@@ -97,7 +108,7 @@ export async function handleDiscordModerationAction(
|
||||
}
|
||||
case "ban": {
|
||||
if (accountId) {
|
||||
await banMemberDiscord(
|
||||
await discordModerationActionRuntime.banMemberDiscord(
|
||||
{
|
||||
guildId: command.guildId,
|
||||
userId: command.userId,
|
||||
@@ -107,7 +118,7 @@ export async function handleDiscordModerationAction(
|
||||
{ accountId },
|
||||
);
|
||||
} else {
|
||||
await banMemberDiscord({
|
||||
await discordModerationActionRuntime.banMemberDiscord({
|
||||
guildId: command.guildId,
|
||||
userId: command.userId,
|
||||
reason: command.reason,
|
||||
@@ -1,12 +1,9 @@
|
||||
import type { GatewayPlugin } from "@buape/carbon/gateway";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearGateways,
|
||||
registerGateway,
|
||||
} from "../../../extensions/discord/src/monitor/gateway-registry.js";
|
||||
import type { DiscordActionConfig } from "../../config/config.js";
|
||||
import type { ActionGate } from "./common.js";
|
||||
import { handleDiscordPresenceAction } from "./discord-actions-presence.js";
|
||||
import type { ActionGate } from "../../../../src/agents/tools/common.js";
|
||||
import type { DiscordActionConfig } from "../../../../src/config/types.discord.js";
|
||||
import { clearGateways, registerGateway } from "../monitor/gateway-registry.js";
|
||||
import { handleDiscordPresenceAction } from "./runtime.presence.js";
|
||||
|
||||
const mockUpdatePresence = vi.fn();
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import type { Activity, UpdatePresenceData } from "@buape/carbon/gateway";
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { DiscordActionConfig } from "../../config/config.js";
|
||||
import { getGateway } from "../../plugin-sdk/discord.js";
|
||||
import { type ActionGate, jsonResult, readStringParam } from "./common.js";
|
||||
import { getGateway } from "../monitor/gateway-registry.js";
|
||||
import {
|
||||
type ActionGate,
|
||||
jsonResult,
|
||||
readStringParam,
|
||||
type DiscordActionConfig,
|
||||
} from "../runtime-api.js";
|
||||
|
||||
const ACTIVITY_TYPE_MAP: Record<string, number> = {
|
||||
playing: 0,
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readStringParam } from "./common.js";
|
||||
import { readStringParam } from "../runtime-api.js";
|
||||
|
||||
export function readDiscordParentIdParam(
|
||||
params: Record<string, unknown>,
|
||||
@@ -1,11 +1,22 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DiscordActionConfig, OpenClawConfig } from "../../config/config.js";
|
||||
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
|
||||
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
|
||||
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
|
||||
import { handleDiscordAction } from "./discord-actions.js";
|
||||
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
||||
import type { DiscordActionConfig } from "../../../../src/config/types.discord.js";
|
||||
import { discordGuildActionRuntime, handleDiscordGuildAction } from "./runtime.guild.js";
|
||||
import { handleDiscordAction } from "./runtime.js";
|
||||
import {
|
||||
discordMessagingActionRuntime,
|
||||
handleDiscordMessagingAction,
|
||||
} from "./runtime.messaging.js";
|
||||
import {
|
||||
discordModerationActionRuntime,
|
||||
handleDiscordModerationAction,
|
||||
} from "./runtime.moderation.js";
|
||||
|
||||
const discordSendMocks = vi.hoisted(() => ({
|
||||
const originalDiscordMessagingActionRuntime = { ...discordMessagingActionRuntime };
|
||||
const originalDiscordGuildActionRuntime = { ...discordGuildActionRuntime };
|
||||
const originalDiscordModerationActionRuntime = { ...discordModerationActionRuntime };
|
||||
|
||||
const discordSendMocks = {
|
||||
banMemberDiscord: vi.fn(async () => ({})),
|
||||
createChannelDiscord: vi.fn(async () => ({
|
||||
id: "new-channel",
|
||||
@@ -42,7 +53,7 @@ const discordSendMocks = vi.hoisted(() => ({
|
||||
setChannelPermissionDiscord: vi.fn(async () => ({ ok: true })),
|
||||
timeoutMemberDiscord: vi.fn(async () => ({})),
|
||||
unpinMessageDiscord: vi.fn(async () => ({})),
|
||||
}));
|
||||
};
|
||||
|
||||
const {
|
||||
createChannelDiscord,
|
||||
@@ -67,21 +78,28 @@ const {
|
||||
timeoutMemberDiscord,
|
||||
} = discordSendMocks;
|
||||
|
||||
vi.mock("../../../extensions/discord/src/send.js", () => ({
|
||||
...discordSendMocks,
|
||||
}));
|
||||
|
||||
const enableAllActions = () => true;
|
||||
|
||||
const disabledActions = (key: keyof DiscordActionConfig) => key !== "reactions";
|
||||
const channelInfoEnabled = (key: keyof DiscordActionConfig) => key === "channelInfo";
|
||||
const moderationEnabled = (key: keyof DiscordActionConfig) => key === "moderation";
|
||||
|
||||
describe("handleDiscordMessagingAction", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.assign(
|
||||
discordMessagingActionRuntime,
|
||||
originalDiscordMessagingActionRuntime,
|
||||
discordSendMocks,
|
||||
);
|
||||
Object.assign(discordGuildActionRuntime, originalDiscordGuildActionRuntime, discordSendMocks);
|
||||
Object.assign(
|
||||
discordModerationActionRuntime,
|
||||
originalDiscordModerationActionRuntime,
|
||||
discordSendMocks,
|
||||
);
|
||||
});
|
||||
|
||||
describe("handleDiscordMessagingAction", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "without account",
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { createDiscordActionGate } from "../../plugin-sdk/discord.js";
|
||||
import { readStringParam } from "./common.js";
|
||||
import { handleDiscordGuildAction } from "./discord-actions-guild.js";
|
||||
import { handleDiscordMessagingAction } from "./discord-actions-messaging.js";
|
||||
import { handleDiscordModerationAction } from "./discord-actions-moderation.js";
|
||||
import { handleDiscordPresenceAction } from "./discord-actions-presence.js";
|
||||
import { createDiscordActionGate } from "../accounts.js";
|
||||
import { readStringParam, type OpenClawConfig } from "../runtime-api.js";
|
||||
import { handleDiscordGuildAction } from "./runtime.guild.js";
|
||||
import { handleDiscordMessagingAction } from "./runtime.messaging.js";
|
||||
import { handleDiscordModerationAction } from "./runtime.moderation.js";
|
||||
import { handleDiscordPresenceAction } from "./runtime.presence.js";
|
||||
|
||||
const messagingActions = new Set([
|
||||
"react",
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ChannelPlugin } from "openclaw/plugin-sdk/discord";
|
||||
import { type ResolvedDiscordAccount } from "./accounts.js";
|
||||
import { type ChannelPlugin } from "./runtime-api.js";
|
||||
import { discordSetupAdapter } from "./setup-core.js";
|
||||
import { createDiscordPluginBase } from "./shared.js";
|
||||
|
||||
|
||||
@@ -209,3 +209,42 @@ describe("discordPlugin outbound", () => {
|
||||
expect(runtimeMonitorDiscordProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("discordPlugin groups", () => {
|
||||
it("uses plugin-owned group policy resolvers", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
token: "discord-test",
|
||||
guilds: {
|
||||
guild1: {
|
||||
requireMention: false,
|
||||
tools: { allow: ["message.guild"] },
|
||||
channels: {
|
||||
"123": {
|
||||
requireMention: true,
|
||||
tools: { allow: ["message.channel"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
expect(
|
||||
discordPlugin.groups?.resolveRequireMention?.({
|
||||
cfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "123",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
discordPlugin.groups?.resolveToolPolicy?.({
|
||||
cfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "123",
|
||||
}),
|
||||
).toEqual({ allow: ["message.channel"] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,30 +3,14 @@ import {
|
||||
buildAccountScopedAllowlistConfigEditor,
|
||||
resolveLegacyDmAllowlistConfigPaths,
|
||||
} from "openclaw/plugin-sdk/allowlist-config-edit";
|
||||
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import {
|
||||
buildAccountScopedDmSecurityPolicy,
|
||||
collectOpenGroupPolicyConfiguredRouteWarnings,
|
||||
collectOpenProviderGroupPolicyWarnings,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core";
|
||||
import {
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildTokenChannelStatusSummary,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
getChatChannelMeta,
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
projectCredentialSnapshotFields,
|
||||
resolveConfiguredFromCredentialStatuses,
|
||||
resolveDiscordGroupRequireMention,
|
||||
resolveDiscordGroupToolPolicy,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/discord";
|
||||
import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
listDiscordAccountIds,
|
||||
@@ -34,10 +18,18 @@ import {
|
||||
type ResolvedDiscordAccount,
|
||||
} from "./accounts.js";
|
||||
import { auditDiscordChannelPermissions, collectDiscordAuditChannelIds } from "./audit.js";
|
||||
import {
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
} from "./directory-config.js";
|
||||
import {
|
||||
isDiscordExecApprovalClientEnabled,
|
||||
shouldSuppressLocalDiscordExecApprovalPrompt,
|
||||
} from "./exec-approvals.js";
|
||||
import {
|
||||
resolveDiscordGroupRequireMention,
|
||||
resolveDiscordGroupToolPolicy,
|
||||
} from "./group-policy.js";
|
||||
import { monitorDiscordProvider } from "./monitor.js";
|
||||
import {
|
||||
looksLikeDiscordTargetId,
|
||||
@@ -46,10 +38,22 @@ import {
|
||||
} from "./normalize.js";
|
||||
import { probeDiscord, type DiscordProbe } from "./probe.js";
|
||||
import { resolveDiscordUserAllowlist } from "./resolve-users.js";
|
||||
import {
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildTokenChannelStatusSummary,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
getChatChannelMeta,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
projectCredentialSnapshotFields,
|
||||
resolveConfiguredFromCredentialStatuses,
|
||||
type OpenClawConfig,
|
||||
} from "./runtime-api.js";
|
||||
import { getDiscordRuntime } from "./runtime.js";
|
||||
import { fetchChannelPermissionsDiscord } from "./send.js";
|
||||
import { discordSetupAdapter } from "./setup-core.js";
|
||||
import { createDiscordPluginBase, discordConfigAccessors } from "./shared.js";
|
||||
import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js";
|
||||
import { collectDiscordStatusIssues } from "./status-issues.js";
|
||||
import { parseDiscordTarget } from "./targets.js";
|
||||
import { DiscordUiContainer } from "./ui.js";
|
||||
@@ -61,6 +65,14 @@ type DiscordSendFn = ReturnType<
|
||||
const meta = getChatChannelMeta("discord");
|
||||
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
|
||||
|
||||
const resolveDiscordDmPolicy = createScopedDmSecurityResolver<ResolvedDiscordAccount>({
|
||||
channelKey: "discord",
|
||||
resolvePolicy: (account) => account.config.dm?.policy,
|
||||
resolveAllowFrom: (account) => account.config.dm?.allowFrom,
|
||||
allowFromPathSuffix: "dm.",
|
||||
normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
|
||||
});
|
||||
|
||||
function formatDiscordIntents(intents?: {
|
||||
messageContent?: string;
|
||||
guildMembers?: string;
|
||||
@@ -79,12 +91,6 @@ function formatDiscordIntents(intents?: {
|
||||
const discordMessageActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: (ctx) =>
|
||||
getDiscordRuntime().channel.discord.messageActions?.describeMessageTool?.(ctx) ?? null,
|
||||
listActions: (ctx) =>
|
||||
getDiscordRuntime().channel.discord.messageActions?.listActions?.(ctx) ?? [],
|
||||
getCapabilities: (ctx) =>
|
||||
getDiscordRuntime().channel.discord.messageActions?.getCapabilities?.(ctx) ?? [],
|
||||
getToolSchema: (ctx) =>
|
||||
getDiscordRuntime().channel.discord.messageActions?.getToolSchema?.(ctx) ?? null,
|
||||
extractToolSend: (ctx) =>
|
||||
getDiscordRuntime().channel.discord.messageActions?.extractToolSend?.(ctx) ?? null,
|
||||
handleAction: async (ctx) => {
|
||||
@@ -301,23 +307,12 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
applyConfigEdit: buildAccountScopedAllowlistConfigEditor({
|
||||
channelId: "discord",
|
||||
normalize: ({ cfg, accountId, values }) =>
|
||||
discordConfigAccessors.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
|
||||
discordConfigAdapter.formatAllowFrom!({ cfg, accountId, allowFrom: values }),
|
||||
resolvePaths: resolveLegacyDmAllowlistConfigPaths,
|
||||
}),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
return buildAccountScopedDmSecurityPolicy({
|
||||
cfg,
|
||||
channelKey: "discord",
|
||||
accountId,
|
||||
fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
|
||||
policy: account.config.dm?.policy,
|
||||
allowFrom: account.config.dm?.allowFrom ?? [],
|
||||
allowFromPathSuffix: "dm.",
|
||||
normalizeEntry: (raw) => raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
|
||||
});
|
||||
},
|
||||
resolveDmPolicy: resolveDiscordDmPolicy,
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
const guildEntries = account.config.guilds ?? {};
|
||||
const guildsConfigured = Object.keys(guildEntries).length > 0;
|
||||
@@ -366,6 +361,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeDiscordMessagingTarget,
|
||||
resolveSessionTarget: ({ id }) => normalizeDiscordMessagingTarget(`channel:${id}`),
|
||||
parseExplicitTarget: ({ raw }) => parseDiscordExplicitTarget(raw),
|
||||
inferTargetChatType: ({ to }) => parseDiscordExplicitTarget(to)?.chatType,
|
||||
buildCrossContextComponents: buildDiscordCrossContextComponents,
|
||||
|
||||
57
extensions/discord/src/directory-config.ts
Normal file
57
extensions/discord/src/directory-config.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
applyDirectoryQueryAndLimit,
|
||||
collectNormalizedDirectoryIds,
|
||||
toDirectoryEntries,
|
||||
type DirectoryConfigParams,
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import type { InspectedDiscordAccount } from "../../../src/channels/read-only-account-inspect.discord.runtime.js";
|
||||
import { inspectReadOnlyChannelAccount } from "../../../src/channels/read-only-account-inspect.js";
|
||||
|
||||
export async function listDiscordDirectoryPeersFromConfig(params: DirectoryConfigParams) {
|
||||
const account = (await inspectReadOnlyChannelAccount({
|
||||
channelId: "discord",
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
})) as InspectedDiscordAccount | null;
|
||||
if (!account || !("config" in account)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allowFrom = account.config.allowFrom ?? account.config.dm?.allowFrom ?? [];
|
||||
const guildUsers = Object.values(account.config.guilds ?? {}).flatMap((guild) => [
|
||||
...(guild.users ?? []),
|
||||
...Object.values(guild.channels ?? {}).flatMap((channel) => channel.users ?? []),
|
||||
]);
|
||||
const ids = collectNormalizedDirectoryIds({
|
||||
sources: [allowFrom, Object.keys(account.config.dms ?? {}), guildUsers],
|
||||
normalizeId: (raw) => {
|
||||
const mention = raw.match(/^<@!?(\d+)>$/);
|
||||
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|user):/i, "").trim();
|
||||
return /^\d+$/.test(cleaned) ? `user:${cleaned}` : null;
|
||||
},
|
||||
});
|
||||
return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params));
|
||||
}
|
||||
|
||||
export async function listDiscordDirectoryGroupsFromConfig(params: DirectoryConfigParams) {
|
||||
const account = (await inspectReadOnlyChannelAccount({
|
||||
channelId: "discord",
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
})) as InspectedDiscordAccount | null;
|
||||
if (!account || !("config" in account)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ids = collectNormalizedDirectoryIds({
|
||||
sources: Object.values(account.config.guilds ?? {}).map((guild) =>
|
||||
Object.keys(guild.channels ?? {}),
|
||||
),
|
||||
normalizeId: (raw) => {
|
||||
const mention = raw.match(/^<#(\d+)>$/);
|
||||
const cleaned = (mention?.[1] ?? raw).replace(/^(discord|channel|group):/i, "").trim();
|
||||
return /^\d+$/.test(cleaned) ? `channel:${cleaned}` : null;
|
||||
},
|
||||
});
|
||||
return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params));
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { DirectoryConfigParams } from "../../../src/channels/plugins/directory-config.js";
|
||||
import type { OpenClawConfig } from "../../../src/config/config.js";
|
||||
import type { DirectoryConfigParams } from "../../../src/plugin-sdk/directory-runtime.js";
|
||||
import { listDiscordDirectoryGroupsLive, listDiscordDirectoryPeersLive } from "./directory-live.js";
|
||||
|
||||
function makeParams(overrides: Partial<DirectoryConfigParams> = {}): DirectoryConfigParams {
|
||||
|
||||
79
extensions/discord/src/group-policy.test.ts
Normal file
79
extensions/discord/src/group-policy.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveDiscordGroupRequireMention,
|
||||
resolveDiscordGroupToolPolicy,
|
||||
} from "./group-policy.js";
|
||||
|
||||
describe("discord group policy", () => {
|
||||
it("prefers channel policy, then guild policy, with sender-specific overrides", () => {
|
||||
const discordCfg = {
|
||||
channels: {
|
||||
discord: {
|
||||
token: "discord-test",
|
||||
guilds: {
|
||||
guild1: {
|
||||
requireMention: false,
|
||||
tools: { allow: ["message.guild"] },
|
||||
toolsBySender: {
|
||||
"id:user:guild-admin": { allow: ["sessions.list"] },
|
||||
},
|
||||
channels: {
|
||||
"123": {
|
||||
requireMention: true,
|
||||
tools: { allow: ["message.channel"] },
|
||||
toolsBySender: {
|
||||
"id:user:channel-admin": { deny: ["exec"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
} as any;
|
||||
|
||||
expect(
|
||||
resolveDiscordGroupRequireMention({ cfg: discordCfg, groupSpace: "guild1", groupId: "123" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveDiscordGroupRequireMention({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "missing",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveDiscordGroupToolPolicy({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "123",
|
||||
senderId: "user:channel-admin",
|
||||
}),
|
||||
).toEqual({ deny: ["exec"] });
|
||||
expect(
|
||||
resolveDiscordGroupToolPolicy({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "123",
|
||||
senderId: "user:someone",
|
||||
}),
|
||||
).toEqual({ allow: ["message.channel"] });
|
||||
expect(
|
||||
resolveDiscordGroupToolPolicy({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "missing",
|
||||
senderId: "user:guild-admin",
|
||||
}),
|
||||
).toEqual({ allow: ["sessions.list"] });
|
||||
expect(
|
||||
resolveDiscordGroupToolPolicy({
|
||||
cfg: discordCfg,
|
||||
groupSpace: "guild1",
|
||||
groupId: "missing",
|
||||
senderId: "user:someone",
|
||||
}),
|
||||
).toEqual({ allow: ["message.guild"] });
|
||||
});
|
||||
});
|
||||
111
extensions/discord/src/group-policy.ts
Normal file
111
extensions/discord/src/group-policy.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
resolveToolsBySender,
|
||||
type GroupToolPolicyBySenderConfig,
|
||||
type GroupToolPolicyConfig,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
import { type ChannelGroupContext } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { normalizeAtHashSlug } from "openclaw/plugin-sdk/core";
|
||||
import type { DiscordConfig } from "openclaw/plugin-sdk/discord";
|
||||
|
||||
function normalizeDiscordSlug(value?: string | null) {
|
||||
return normalizeAtHashSlug(value);
|
||||
}
|
||||
|
||||
type SenderScopedToolsEntry = {
|
||||
tools?: GroupToolPolicyConfig;
|
||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||
requireMention?: boolean;
|
||||
};
|
||||
|
||||
function resolveDiscordGuildEntry(guilds: DiscordConfig["guilds"], groupSpace?: string | null) {
|
||||
if (!guilds || Object.keys(guilds).length === 0) {
|
||||
return null;
|
||||
}
|
||||
const space = groupSpace?.trim() ?? "";
|
||||
if (space && guilds[space]) {
|
||||
return guilds[space];
|
||||
}
|
||||
const normalized = normalizeDiscordSlug(space);
|
||||
if (normalized && guilds[normalized]) {
|
||||
return guilds[normalized];
|
||||
}
|
||||
if (normalized) {
|
||||
const match = Object.values(guilds).find(
|
||||
(entry) => normalizeDiscordSlug(entry?.slug ?? undefined) === normalized,
|
||||
);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return guilds["*"] ?? null;
|
||||
}
|
||||
|
||||
function resolveDiscordChannelEntry<TEntry extends SenderScopedToolsEntry>(
|
||||
channelEntries: Record<string, TEntry> | undefined,
|
||||
params: { groupId?: string | null; groupChannel?: string | null },
|
||||
): TEntry | undefined {
|
||||
if (!channelEntries || Object.keys(channelEntries).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const groupChannel = params.groupChannel;
|
||||
const channelSlug = normalizeDiscordSlug(groupChannel);
|
||||
return (
|
||||
(params.groupId ? channelEntries[params.groupId] : undefined) ??
|
||||
(channelSlug
|
||||
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
|
||||
: undefined) ??
|
||||
(groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveSenderToolsEntry(
|
||||
entry: SenderScopedToolsEntry | undefined | null,
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
const senderPolicy = resolveToolsBySender({
|
||||
toolsBySender: entry.toolsBySender,
|
||||
senderId: params.senderId,
|
||||
senderName: params.senderName,
|
||||
senderUsername: params.senderUsername,
|
||||
senderE164: params.senderE164,
|
||||
});
|
||||
return senderPolicy ?? entry.tools;
|
||||
}
|
||||
|
||||
function resolveDiscordPolicyContext(params: ChannelGroupContext) {
|
||||
const guildEntry = resolveDiscordGuildEntry(
|
||||
params.cfg.channels?.discord?.guilds,
|
||||
params.groupSpace,
|
||||
);
|
||||
const channelEntries = guildEntry?.channels;
|
||||
const channelEntry =
|
||||
channelEntries && Object.keys(channelEntries).length > 0
|
||||
? resolveDiscordChannelEntry(channelEntries, params)
|
||||
: undefined;
|
||||
return { guildEntry, channelEntry };
|
||||
}
|
||||
|
||||
export function resolveDiscordGroupRequireMention(params: ChannelGroupContext): boolean {
|
||||
const context = resolveDiscordPolicyContext(params);
|
||||
if (typeof context.channelEntry?.requireMention === "boolean") {
|
||||
return context.channelEntry.requireMention;
|
||||
}
|
||||
if (typeof context.guildEntry?.requireMention === "boolean") {
|
||||
return context.guildEntry.requireMention;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveDiscordGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const context = resolveDiscordPolicyContext(params);
|
||||
const channelPolicy = resolveSenderToolsEntry(context.channelEntry, params);
|
||||
if (channelPolicy) {
|
||||
return channelPolicy;
|
||||
}
|
||||
return resolveSenderToolsEntry(context.guildEntry, params);
|
||||
}
|
||||
754
extensions/discord/src/monitor/agent-components-helpers.ts
Normal file
754
extensions/discord/src/monitor/agent-components-helpers.ts
Normal file
@@ -0,0 +1,754 @@
|
||||
import {
|
||||
type ButtonInteraction,
|
||||
type ChannelSelectMenuInteraction,
|
||||
type ComponentData,
|
||||
type MentionableSelectMenuInteraction,
|
||||
type ModalInteraction,
|
||||
type RoleSelectMenuInteraction,
|
||||
type StringSelectMenuInteraction,
|
||||
type UserSelectMenuInteraction,
|
||||
} from "@buape/carbon";
|
||||
import type { APIStringSelectComponent } from "discord-api-types/v10";
|
||||
import { ChannelType } from "discord-api-types/v10";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
issuePairingChallenge,
|
||||
upsertChannelPairingRequest,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolvePinnedMainDmOwnerFromAllowlist,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
import { logError } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
createDiscordFormModal,
|
||||
parseDiscordComponentCustomId,
|
||||
parseDiscordModalCustomId,
|
||||
type DiscordComponentEntry,
|
||||
type DiscordModalEntry,
|
||||
} from "../components.js";
|
||||
import {
|
||||
type DiscordGuildEntryResolved,
|
||||
normalizeDiscordAllowList,
|
||||
normalizeDiscordSlug,
|
||||
resolveDiscordAllowListMatch,
|
||||
resolveDiscordChannelConfigWithFallback,
|
||||
resolveDiscordGuildEntry,
|
||||
resolveDiscordMemberAccessState,
|
||||
resolveDiscordOwnerAccess,
|
||||
} from "./allow-list.js";
|
||||
import { formatDiscordUserTag } from "./format.js";
|
||||
|
||||
export const AGENT_BUTTON_KEY = "agent";
|
||||
export const AGENT_SELECT_KEY = "agentsel";
|
||||
|
||||
export type DiscordUser = Parameters<typeof formatDiscordUserTag>[0];
|
||||
|
||||
export type AgentComponentMessageInteraction =
|
||||
| ButtonInteraction
|
||||
| StringSelectMenuInteraction
|
||||
| RoleSelectMenuInteraction
|
||||
| UserSelectMenuInteraction
|
||||
| MentionableSelectMenuInteraction
|
||||
| ChannelSelectMenuInteraction;
|
||||
|
||||
export type AgentComponentInteraction = AgentComponentMessageInteraction | ModalInteraction;
|
||||
|
||||
export type DiscordChannelContext = {
|
||||
channelName: string | undefined;
|
||||
channelSlug: string;
|
||||
channelType: number | undefined;
|
||||
isThread: boolean;
|
||||
parentId: string | undefined;
|
||||
parentName: string | undefined;
|
||||
parentSlug: string;
|
||||
};
|
||||
|
||||
export type AgentComponentContext = {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
discordConfig?: DiscordAccountConfig;
|
||||
runtime?: import("openclaw/plugin-sdk/runtime-env").RuntimeEnv;
|
||||
token?: string;
|
||||
guildEntries?: Record<string, DiscordGuildEntryResolved>;
|
||||
allowFrom?: string[];
|
||||
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
|
||||
};
|
||||
|
||||
export type ComponentInteractionContext = NonNullable<
|
||||
Awaited<ReturnType<typeof resolveComponentInteractionContext>>
|
||||
>;
|
||||
|
||||
function formatUsername(user: { username: string; discriminator?: string | null }): string {
|
||||
if (user.discriminator && user.discriminator !== "0") {
|
||||
return `${user.username}#${user.discriminator}`;
|
||||
}
|
||||
return user.username;
|
||||
}
|
||||
|
||||
function isThreadChannelType(channelType: number | undefined): boolean {
|
||||
return (
|
||||
channelType === ChannelType.PublicThread ||
|
||||
channelType === ChannelType.PrivateThread ||
|
||||
channelType === ChannelType.AnnouncementThread
|
||||
);
|
||||
}
|
||||
|
||||
function readParsedComponentId(data: ComponentData): unknown {
|
||||
if (!data || typeof data !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
return "cid" in data
|
||||
? (data as Record<string, unknown>).cid
|
||||
: (data as Record<string, unknown>).componentId;
|
||||
}
|
||||
|
||||
function normalizeComponentId(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function mapOptionLabels(
|
||||
options: Array<{ value: string; label: string }> | undefined,
|
||||
values: string[],
|
||||
) {
|
||||
if (!options || options.length === 0) {
|
||||
return values;
|
||||
}
|
||||
const map = new Map(options.map((option) => [option.value, option.label]));
|
||||
return values.map((value) => map.get(value) ?? value);
|
||||
}
|
||||
|
||||
/**
|
||||
* The component custom id only carries the logical button id. Channel binding
|
||||
* comes from Discord's trusted interaction payload.
|
||||
*/
|
||||
export function buildAgentButtonCustomId(componentId: string): string {
|
||||
return `${AGENT_BUTTON_KEY}:componentId=${encodeURIComponent(componentId)}`;
|
||||
}
|
||||
|
||||
export function buildAgentSelectCustomId(componentId: string): string {
|
||||
return `${AGENT_SELECT_KEY}:componentId=${encodeURIComponent(componentId)}`;
|
||||
}
|
||||
|
||||
export function resolveAgentComponentRoute(params: {
|
||||
ctx: AgentComponentContext;
|
||||
rawGuildId: string | undefined;
|
||||
memberRoleIds: string[];
|
||||
isDirectMessage: boolean;
|
||||
userId: string;
|
||||
channelId: string;
|
||||
parentId: string | undefined;
|
||||
}) {
|
||||
return resolveAgentRoute({
|
||||
cfg: params.ctx.cfg,
|
||||
channel: "discord",
|
||||
accountId: params.ctx.accountId,
|
||||
guildId: params.rawGuildId,
|
||||
memberRoleIds: params.memberRoleIds,
|
||||
peer: {
|
||||
kind: params.isDirectMessage ? "direct" : "channel",
|
||||
id: params.isDirectMessage ? params.userId : params.channelId,
|
||||
},
|
||||
parentPeer: params.parentId ? { kind: "channel", id: params.parentId } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export async function ackComponentInteraction(params: {
|
||||
interaction: AgentComponentInteraction;
|
||||
replyOpts: { ephemeral?: boolean };
|
||||
label: string;
|
||||
}) {
|
||||
try {
|
||||
await params.interaction.reply({
|
||||
content: "✓",
|
||||
...params.replyOpts,
|
||||
});
|
||||
} catch (err) {
|
||||
logError(`${params.label}: failed to acknowledge interaction: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveDiscordChannelContext(
|
||||
interaction: AgentComponentInteraction,
|
||||
): DiscordChannelContext {
|
||||
const channel = interaction.channel;
|
||||
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
|
||||
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
||||
const channelType = channel && "type" in channel ? (channel.type as number) : undefined;
|
||||
const isThread = isThreadChannelType(channelType);
|
||||
|
||||
let parentId: string | undefined;
|
||||
let parentName: string | undefined;
|
||||
let parentSlug = "";
|
||||
if (isThread && channel && "parentId" in channel) {
|
||||
parentId = (channel.parentId as string) ?? undefined;
|
||||
if ("parent" in channel) {
|
||||
const parent = (channel as { parent?: { name?: string } }).parent;
|
||||
if (parent?.name) {
|
||||
parentName = parent.name;
|
||||
parentSlug = normalizeDiscordSlug(parentName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { channelName, channelSlug, channelType, isThread, parentId, parentName, parentSlug };
|
||||
}
|
||||
|
||||
export async function resolveComponentInteractionContext(params: {
|
||||
interaction: AgentComponentInteraction;
|
||||
label: string;
|
||||
defer?: boolean;
|
||||
}) {
|
||||
const { interaction, label } = params;
|
||||
const channelId = interaction.rawData.channel_id;
|
||||
if (!channelId) {
|
||||
logError(`${label}: missing channel_id in interaction`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = interaction.user;
|
||||
if (!user) {
|
||||
logError(`${label}: missing user in interaction`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldDefer = params.defer !== false && "defer" in interaction;
|
||||
let didDefer = false;
|
||||
if (shouldDefer) {
|
||||
try {
|
||||
await (interaction as AgentComponentMessageInteraction).defer({ ephemeral: true });
|
||||
didDefer = true;
|
||||
} catch (err) {
|
||||
logError(`${label}: failed to defer interaction: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
const replyOpts = didDefer ? {} : { ephemeral: true };
|
||||
|
||||
const username = formatUsername(user);
|
||||
const userId = user.id;
|
||||
const rawGuildId = interaction.rawData.guild_id;
|
||||
const isDirectMessage = !rawGuildId;
|
||||
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
|
||||
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
|
||||
: [];
|
||||
|
||||
return {
|
||||
channelId,
|
||||
user,
|
||||
username,
|
||||
userId,
|
||||
replyOpts,
|
||||
rawGuildId,
|
||||
isDirectMessage,
|
||||
memberRoleIds,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureGuildComponentMemberAllowed(params: {
|
||||
interaction: AgentComponentInteraction;
|
||||
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
|
||||
channelId: string;
|
||||
rawGuildId: string | undefined;
|
||||
channelCtx: DiscordChannelContext;
|
||||
memberRoleIds: string[];
|
||||
user: DiscordUser;
|
||||
replyOpts: { ephemeral?: boolean };
|
||||
componentLabel: string;
|
||||
unauthorizedReply: string;
|
||||
allowNameMatching: boolean;
|
||||
}) {
|
||||
const {
|
||||
interaction,
|
||||
guildInfo,
|
||||
channelId,
|
||||
rawGuildId,
|
||||
channelCtx,
|
||||
memberRoleIds,
|
||||
user,
|
||||
replyOpts,
|
||||
componentLabel,
|
||||
unauthorizedReply,
|
||||
} = params;
|
||||
|
||||
if (!rawGuildId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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 } = resolveDiscordMemberAccessState({
|
||||
channelConfig,
|
||||
guildInfo,
|
||||
memberRoleIds,
|
||||
sender: {
|
||||
id: user.id,
|
||||
name: user.username,
|
||||
tag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
|
||||
},
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
});
|
||||
if (memberAllowed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
logVerbose(`agent ${componentLabel}: blocked user ${user.id} (not in users/roles allowlist)`);
|
||||
try {
|
||||
await interaction.reply({
|
||||
content: unauthorizedReply,
|
||||
...replyOpts,
|
||||
});
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function ensureComponentUserAllowed(params: {
|
||||
entry: DiscordComponentEntry;
|
||||
interaction: AgentComponentInteraction;
|
||||
user: DiscordUser;
|
||||
replyOpts: { ephemeral?: boolean };
|
||||
componentLabel: string;
|
||||
unauthorizedReply: string;
|
||||
allowNameMatching: boolean;
|
||||
}) {
|
||||
const allowList = normalizeDiscordAllowList(params.entry.allowedUsers, [
|
||||
"discord:",
|
||||
"user:",
|
||||
"pk:",
|
||||
]);
|
||||
if (!allowList) {
|
||||
return true;
|
||||
}
|
||||
const match = resolveDiscordAllowListMatch({
|
||||
allowList,
|
||||
candidate: {
|
||||
id: params.user.id,
|
||||
name: params.user.username,
|
||||
tag: formatDiscordUserTag(params.user),
|
||||
},
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
});
|
||||
if (match.allowed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
logVerbose(
|
||||
`discord component ${params.componentLabel}: blocked user ${params.user.id} (not in allowedUsers)`,
|
||||
);
|
||||
try {
|
||||
await params.interaction.reply({
|
||||
content: params.unauthorizedReply,
|
||||
...params.replyOpts,
|
||||
});
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function ensureAgentComponentInteractionAllowed(params: {
|
||||
ctx: AgentComponentContext;
|
||||
interaction: AgentComponentInteraction;
|
||||
channelId: string;
|
||||
rawGuildId: string | undefined;
|
||||
memberRoleIds: string[];
|
||||
user: DiscordUser;
|
||||
replyOpts: { ephemeral?: boolean };
|
||||
componentLabel: string;
|
||||
unauthorizedReply: string;
|
||||
}) {
|
||||
const guildInfo = resolveDiscordGuildEntry({
|
||||
guild: params.interaction.guild ?? undefined,
|
||||
guildId: params.rawGuildId,
|
||||
guildEntries: params.ctx.guildEntries,
|
||||
});
|
||||
const channelCtx = resolveDiscordChannelContext(params.interaction);
|
||||
const memberAllowed = await ensureGuildComponentMemberAllowed({
|
||||
interaction: params.interaction,
|
||||
guildInfo,
|
||||
channelId: params.channelId,
|
||||
rawGuildId: params.rawGuildId,
|
||||
channelCtx,
|
||||
memberRoleIds: params.memberRoleIds,
|
||||
user: params.user,
|
||||
replyOpts: params.replyOpts,
|
||||
componentLabel: params.componentLabel,
|
||||
unauthorizedReply: params.unauthorizedReply,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
|
||||
});
|
||||
if (!memberAllowed) {
|
||||
return null;
|
||||
}
|
||||
return { parentId: channelCtx.parentId };
|
||||
}
|
||||
|
||||
export function parseAgentComponentData(data: ComponentData): { componentId: string } | null {
|
||||
const raw = readParsedComponentId(data);
|
||||
const decodeSafe = (value: string): string => {
|
||||
if (!value.includes("%")) {
|
||||
return value;
|
||||
}
|
||||
if (!/%[0-9A-Fa-f]{2}/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
const componentId =
|
||||
typeof raw === "string" ? decodeSafe(raw) : typeof raw === "number" ? String(raw) : null;
|
||||
if (!componentId) {
|
||||
return null;
|
||||
}
|
||||
return { componentId };
|
||||
}
|
||||
|
||||
async function ensureDmComponentAuthorized(params: {
|
||||
ctx: AgentComponentContext;
|
||||
interaction: AgentComponentInteraction;
|
||||
user: DiscordUser;
|
||||
componentLabel: string;
|
||||
replyOpts: { ephemeral?: boolean };
|
||||
}) {
|
||||
const { ctx, interaction, user, componentLabel, replyOpts } = params;
|
||||
const dmPolicy = ctx.dmPolicy ?? "pairing";
|
||||
if (dmPolicy === "disabled") {
|
||||
logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`);
|
||||
try {
|
||||
await interaction.reply({
|
||||
content: "DM interactions are disabled.",
|
||||
...replyOpts,
|
||||
});
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
if (dmPolicy === "open") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
|
||||
provider: "discord",
|
||||
accountId: ctx.accountId,
|
||||
dmPolicy,
|
||||
});
|
||||
const effectiveAllowFrom = [...(ctx.allowFrom ?? []), ...storeAllowFrom];
|
||||
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
|
||||
const allowMatch = allowList
|
||||
? resolveDiscordAllowListMatch({
|
||||
allowList,
|
||||
candidate: {
|
||||
id: user.id,
|
||||
name: user.username,
|
||||
tag: formatDiscordUserTag(user),
|
||||
},
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig),
|
||||
})
|
||||
: { allowed: false };
|
||||
if (allowMatch.allowed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (dmPolicy === "pairing") {
|
||||
const pairingResult = await issuePairingChallenge({
|
||||
channel: "discord",
|
||||
senderId: user.id,
|
||||
senderIdLine: `Your Discord user id: ${user.id}`,
|
||||
meta: {
|
||||
tag: formatDiscordUserTag(user),
|
||||
name: user.username,
|
||||
},
|
||||
upsertPairingRequest: async ({ id, meta }) =>
|
||||
await upsertChannelPairingRequest({
|
||||
channel: "discord",
|
||||
id,
|
||||
accountId: ctx.accountId,
|
||||
meta,
|
||||
}),
|
||||
sendPairingReply: async (text) => {
|
||||
await interaction.reply({
|
||||
content: text,
|
||||
...replyOpts,
|
||||
});
|
||||
},
|
||||
});
|
||||
if (!pairingResult.created) {
|
||||
try {
|
||||
await interaction.reply({
|
||||
content: "Pairing already requested. Ask the bot owner to approve your code.",
|
||||
...replyOpts,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`);
|
||||
try {
|
||||
await interaction.reply({
|
||||
content: `You are not authorized to use this ${componentLabel}.`,
|
||||
...replyOpts,
|
||||
});
|
||||
} catch {}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function resolveInteractionContextWithDmAuth(params: {
|
||||
ctx: AgentComponentContext;
|
||||
interaction: AgentComponentInteraction;
|
||||
label: string;
|
||||
componentLabel: string;
|
||||
defer?: boolean;
|
||||
}) {
|
||||
const interactionCtx = await resolveComponentInteractionContext({
|
||||
interaction: params.interaction,
|
||||
label: params.label,
|
||||
defer: params.defer,
|
||||
});
|
||||
if (!interactionCtx) {
|
||||
return null;
|
||||
}
|
||||
if (interactionCtx.isDirectMessage) {
|
||||
const authorized = await ensureDmComponentAuthorized({
|
||||
ctx: params.ctx,
|
||||
interaction: params.interaction,
|
||||
user: interactionCtx.user,
|
||||
componentLabel: params.componentLabel,
|
||||
replyOpts: interactionCtx.replyOpts,
|
||||
});
|
||||
if (!authorized) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return interactionCtx;
|
||||
}
|
||||
|
||||
export function parseDiscordComponentData(
|
||||
data: ComponentData,
|
||||
customId?: string,
|
||||
): { componentId: string; modalId?: string } | null {
|
||||
if (!data || typeof data !== "object") {
|
||||
return null;
|
||||
}
|
||||
const rawComponentId = readParsedComponentId(data);
|
||||
const rawModalId =
|
||||
"mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId;
|
||||
let componentId = normalizeComponentId(rawComponentId);
|
||||
let modalId = normalizeComponentId(rawModalId);
|
||||
if (!componentId && customId) {
|
||||
const parsed = parseDiscordComponentCustomId(customId);
|
||||
if (parsed) {
|
||||
componentId = parsed.componentId;
|
||||
modalId = parsed.modalId;
|
||||
}
|
||||
}
|
||||
if (!componentId) {
|
||||
return null;
|
||||
}
|
||||
return { componentId, modalId };
|
||||
}
|
||||
|
||||
export function parseDiscordModalId(data: ComponentData, customId?: string): string | null {
|
||||
if (data && typeof data === "object") {
|
||||
const rawModalId =
|
||||
"mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId;
|
||||
const modalId = normalizeComponentId(rawModalId);
|
||||
if (modalId) {
|
||||
return modalId;
|
||||
}
|
||||
}
|
||||
if (customId) {
|
||||
return parseDiscordModalCustomId(customId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveInteractionCustomId(
|
||||
interaction: AgentComponentInteraction,
|
||||
): string | undefined {
|
||||
if (!interaction?.rawData || typeof interaction.rawData !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
if (!("data" in interaction.rawData)) {
|
||||
return undefined;
|
||||
}
|
||||
const data = (interaction.rawData as { data?: { custom_id?: unknown } }).data;
|
||||
const customId = data?.custom_id;
|
||||
if (typeof customId !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = customId.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function mapSelectValues(entry: DiscordComponentEntry, values: string[]): string[] {
|
||||
if (entry.selectType === "string") {
|
||||
return mapOptionLabels(entry.options, values);
|
||||
}
|
||||
if (entry.selectType === "user") {
|
||||
return values.map((value) => `user:${value}`);
|
||||
}
|
||||
if (entry.selectType === "role") {
|
||||
return values.map((value) => `role:${value}`);
|
||||
}
|
||||
if (entry.selectType === "mentionable") {
|
||||
return values.map((value) => `mentionable:${value}`);
|
||||
}
|
||||
if (entry.selectType === "channel") {
|
||||
return values.map((value) => `channel:${value}`);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function resolveModalFieldValues(
|
||||
field: DiscordModalEntry["fields"][number],
|
||||
interaction: ModalInteraction,
|
||||
): string[] {
|
||||
const fields = interaction.fields;
|
||||
const optionLabels = field.options?.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}));
|
||||
const required = field.required === true;
|
||||
try {
|
||||
switch (field.type) {
|
||||
case "text": {
|
||||
const value = required ? fields.getText(field.id, true) : fields.getText(field.id);
|
||||
return value ? [value] : [];
|
||||
}
|
||||
case "select":
|
||||
case "checkbox":
|
||||
case "radio": {
|
||||
const values = required
|
||||
? fields.getStringSelect(field.id, true)
|
||||
: (fields.getStringSelect(field.id) ?? []);
|
||||
return mapOptionLabels(optionLabels, values);
|
||||
}
|
||||
case "role-select": {
|
||||
try {
|
||||
const roles = required
|
||||
? fields.getRoleSelect(field.id, true)
|
||||
: (fields.getRoleSelect(field.id) ?? []);
|
||||
return roles.map((role) => role.name ?? role.id);
|
||||
} catch {
|
||||
const values = required
|
||||
? fields.getStringSelect(field.id, true)
|
||||
: (fields.getStringSelect(field.id) ?? []);
|
||||
return values;
|
||||
}
|
||||
}
|
||||
case "user-select": {
|
||||
const users = required
|
||||
? fields.getUserSelect(field.id, true)
|
||||
: (fields.getUserSelect(field.id) ?? []);
|
||||
return users.map((user) => formatDiscordUserTag(user));
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
} catch (err) {
|
||||
logError(`agent modal: failed to read field ${field.id}: ${String(err)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function formatModalSubmissionText(
|
||||
entry: DiscordModalEntry,
|
||||
interaction: ModalInteraction,
|
||||
): string {
|
||||
const lines: string[] = [`Form "${entry.title}" submitted.`];
|
||||
for (const field of entry.fields) {
|
||||
const values = resolveModalFieldValues(field, interaction);
|
||||
if (values.length === 0) {
|
||||
continue;
|
||||
}
|
||||
lines.push(`- ${field.label}: ${values.join(", ")}`);
|
||||
}
|
||||
if (lines.length === 1) {
|
||||
lines.push("- (no values)");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export 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()}`;
|
||||
}
|
||||
|
||||
export function resolveComponentCommandAuthorized(params: {
|
||||
ctx: AgentComponentContext;
|
||||
interactionCtx: ComponentInteractionContext;
|
||||
channelConfig: ReturnType<typeof resolveDiscordChannelConfigWithFallback>;
|
||||
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
|
||||
allowNameMatching: boolean;
|
||||
}) {
|
||||
const { ctx, interactionCtx, channelConfig, guildInfo } = params;
|
||||
if (interactionCtx.isDirectMessage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({
|
||||
allowFrom: ctx.allowFrom,
|
||||
sender: {
|
||||
id: interactionCtx.user.id,
|
||||
name: interactionCtx.user.username,
|
||||
tag: formatDiscordUserTag(interactionCtx.user),
|
||||
},
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
});
|
||||
|
||||
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
|
||||
channelConfig,
|
||||
guildInfo,
|
||||
memberRoleIds: interactionCtx.memberRoleIds,
|
||||
sender: {
|
||||
id: interactionCtx.user.id,
|
||||
name: interactionCtx.user.username,
|
||||
tag: formatDiscordUserTag(interactionCtx.user),
|
||||
},
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
});
|
||||
const useAccessGroups = ctx.cfg.commands?.useAccessGroups !== false;
|
||||
const authorizers = useAccessGroups
|
||||
? [
|
||||
{ configured: ownerAllowList != null, allowed: ownerOk },
|
||||
{ configured: hasAccessRestrictions, allowed: memberAllowed },
|
||||
]
|
||||
: [{ configured: hasAccessRestrictions, allowed: memberAllowed }];
|
||||
|
||||
return resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers,
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
});
|
||||
}
|
||||
|
||||
export { resolveDiscordGuildEntry, resolvePinnedMainDmOwnerFromAllowlist };
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
import type { APIStringSelectComponent } from "discord-api-types/v10";
|
||||
import { ButtonStyle, ChannelType } from "discord-api-types/v10";
|
||||
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { createReplyPrefixOptions } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
@@ -27,8 +26,6 @@ import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runti
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { issuePairingChallenge } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import {
|
||||
buildPluginBindingResolvedText,
|
||||
parsePluginBindingApprovalCustomId,
|
||||
@@ -48,32 +45,51 @@ import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolvePinnedMainDmOwnerFromAllowlist,
|
||||
} from "openclaw/plugin-sdk/security-runtime";
|
||||
import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
|
||||
import { resolveDiscordComponentEntry, resolveDiscordModalEntry } from "../components-registry.js";
|
||||
import {
|
||||
createDiscordFormModal,
|
||||
formatDiscordComponentEventText,
|
||||
parseDiscordComponentCustomId,
|
||||
parseDiscordComponentCustomIdForCarbon,
|
||||
parseDiscordModalCustomId,
|
||||
parseDiscordModalCustomIdForCarbon,
|
||||
type DiscordComponentEntry,
|
||||
type DiscordModalEntry,
|
||||
} from "../components.js";
|
||||
import {
|
||||
AGENT_BUTTON_KEY,
|
||||
AGENT_SELECT_KEY,
|
||||
ackComponentInteraction,
|
||||
buildAgentButtonCustomId,
|
||||
buildAgentSelectCustomId,
|
||||
type AgentComponentContext,
|
||||
type AgentComponentInteraction,
|
||||
type AgentComponentMessageInteraction,
|
||||
ensureAgentComponentInteractionAllowed,
|
||||
ensureComponentUserAllowed,
|
||||
ensureGuildComponentMemberAllowed,
|
||||
formatModalSubmissionText,
|
||||
mapSelectValues,
|
||||
parseAgentComponentData,
|
||||
parseDiscordComponentData,
|
||||
parseDiscordModalId,
|
||||
resolveAgentComponentRoute,
|
||||
resolveComponentCommandAuthorized,
|
||||
type ComponentInteractionContext,
|
||||
resolveDiscordChannelContext,
|
||||
type DiscordChannelContext,
|
||||
resolveDiscordInteractionId,
|
||||
resolveInteractionContextWithDmAuth,
|
||||
resolveInteractionCustomId,
|
||||
resolveModalFieldValues,
|
||||
resolvePinnedMainDmOwnerFromAllowlist,
|
||||
type DiscordUser,
|
||||
} from "./agent-components-helpers.js";
|
||||
import {
|
||||
type DiscordGuildEntryResolved,
|
||||
normalizeDiscordAllowList,
|
||||
normalizeDiscordSlug,
|
||||
resolveDiscordAllowListMatch,
|
||||
resolveDiscordChannelConfigWithFallback,
|
||||
resolveDiscordGuildEntry,
|
||||
resolveDiscordMemberAccessState,
|
||||
resolveDiscordOwnerAccess,
|
||||
} from "./allow-list.js";
|
||||
import { formatDiscordUserTag } from "./format.js";
|
||||
import {
|
||||
@@ -84,714 +100,6 @@ import { buildDirectLabel, buildGuildLabel } from "./reply-context.js";
|
||||
import { deliverDiscordReply } from "./reply-delivery.js";
|
||||
import { sendTyping } from "./typing.js";
|
||||
|
||||
const AGENT_BUTTON_KEY = "agent";
|
||||
const AGENT_SELECT_KEY = "agentsel";
|
||||
|
||||
type DiscordUser = Parameters<typeof formatDiscordUserTag>[0];
|
||||
|
||||
type AgentComponentMessageInteraction =
|
||||
| ButtonInteraction
|
||||
| StringSelectMenuInteraction
|
||||
| RoleSelectMenuInteraction
|
||||
| UserSelectMenuInteraction
|
||||
| MentionableSelectMenuInteraction
|
||||
| ChannelSelectMenuInteraction;
|
||||
|
||||
type AgentComponentInteraction = AgentComponentMessageInteraction | ModalInteraction;
|
||||
|
||||
type ComponentInteractionContext = NonNullable<
|
||||
Awaited<ReturnType<typeof resolveComponentInteractionContext>>
|
||||
>;
|
||||
|
||||
type DiscordChannelContext = {
|
||||
channelName: string | undefined;
|
||||
channelSlug: string;
|
||||
channelType: number | undefined;
|
||||
isThread: boolean;
|
||||
parentId: string | undefined;
|
||||
parentName: string | undefined;
|
||||
parentSlug: string;
|
||||
};
|
||||
|
||||
function resolveAgentComponentRoute(params: {
|
||||
ctx: AgentComponentContext;
|
||||
rawGuildId: string | undefined;
|
||||
memberRoleIds: string[];
|
||||
isDirectMessage: boolean;
|
||||
userId: string;
|
||||
channelId: string;
|
||||
parentId: string | undefined;
|
||||
}) {
|
||||
return resolveAgentRoute({
|
||||
cfg: params.ctx.cfg,
|
||||
channel: "discord",
|
||||
accountId: params.ctx.accountId,
|
||||
guildId: params.rawGuildId,
|
||||
memberRoleIds: params.memberRoleIds,
|
||||
peer: {
|
||||
kind: params.isDirectMessage ? "direct" : "channel",
|
||||
id: params.isDirectMessage ? params.userId : params.channelId,
|
||||
},
|
||||
parentPeer: params.parentId ? { kind: "channel", id: params.parentId } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function ackComponentInteraction(params: {
|
||||
interaction: AgentComponentInteraction;
|
||||
replyOpts: { ephemeral?: boolean };
|
||||
label: string;
|
||||
}) {
|
||||
try {
|
||||
await params.interaction.reply({
|
||||
content: "✓",
|
||||
...params.replyOpts,
|
||||
});
|
||||
} catch (err) {
|
||||
logError(`${params.label}: failed to acknowledge interaction: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDiscordChannelContext(
|
||||
interaction: AgentComponentInteraction,
|
||||
): DiscordChannelContext {
|
||||
const channel = interaction.channel;
|
||||
const channelName = channel && "name" in channel ? (channel.name as string) : undefined;
|
||||
const channelSlug = channelName ? normalizeDiscordSlug(channelName) : "";
|
||||
const channelType = channel && "type" in channel ? (channel.type as number) : undefined;
|
||||
const isThread = isThreadChannelType(channelType);
|
||||
|
||||
let parentId: string | undefined;
|
||||
let parentName: string | undefined;
|
||||
let parentSlug = "";
|
||||
if (isThread && channel && "parentId" in channel) {
|
||||
parentId = (channel.parentId as string) ?? undefined;
|
||||
if ("parent" in channel) {
|
||||
const parent = (channel as { parent?: { name?: string } }).parent;
|
||||
if (parent?.name) {
|
||||
parentName = parent.name;
|
||||
parentSlug = normalizeDiscordSlug(parentName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { channelName, channelSlug, channelType, isThread, parentId, parentName, parentSlug };
|
||||
}
|
||||
|
||||
async function resolveComponentInteractionContext(params: {
|
||||
interaction: AgentComponentInteraction;
|
||||
label: string;
|
||||
defer?: boolean;
|
||||
}): Promise<{
|
||||
channelId: string;
|
||||
user: DiscordUser;
|
||||
username: string;
|
||||
userId: string;
|
||||
replyOpts: { ephemeral?: boolean };
|
||||
rawGuildId: string | undefined;
|
||||
isDirectMessage: boolean;
|
||||
memberRoleIds: string[];
|
||||
} | null> {
|
||||
const { interaction, label } = params;
|
||||
|
||||
// Use interaction's actual channel_id (trusted source from Discord)
|
||||
// This prevents channel spoofing attacks
|
||||
const channelId = interaction.rawData.channel_id;
|
||||
if (!channelId) {
|
||||
logError(`${label}: missing channel_id in interaction`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const user = interaction.user;
|
||||
if (!user) {
|
||||
logError(`${label}: missing user in interaction`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldDefer = params.defer !== false && "defer" in interaction;
|
||||
let didDefer = false;
|
||||
// Defer immediately to satisfy Discord's 3-second interaction ACK requirement.
|
||||
// We use an ephemeral deferred reply so subsequent interaction.reply() calls
|
||||
// can safely edit the original deferred response.
|
||||
if (shouldDefer) {
|
||||
try {
|
||||
await (interaction as AgentComponentMessageInteraction).defer({ ephemeral: true });
|
||||
didDefer = true;
|
||||
} catch (err) {
|
||||
logError(`${label}: failed to defer interaction: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
const replyOpts = didDefer ? {} : { ephemeral: true };
|
||||
|
||||
const username = formatUsername(user);
|
||||
const userId = user.id;
|
||||
|
||||
// P1 FIX: Use rawData.guild_id as source of truth - interaction.guild can be null
|
||||
// when guild is not cached even though guild_id is present in rawData
|
||||
const rawGuildId = interaction.rawData.guild_id;
|
||||
const isDirectMessage = !rawGuildId;
|
||||
const memberRoleIds = Array.isArray(interaction.rawData.member?.roles)
|
||||
? interaction.rawData.member.roles.map((roleId: string) => String(roleId))
|
||||
: [];
|
||||
|
||||
return {
|
||||
channelId,
|
||||
user,
|
||||
username,
|
||||
userId,
|
||||
replyOpts,
|
||||
rawGuildId,
|
||||
isDirectMessage,
|
||||
memberRoleIds,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureGuildComponentMemberAllowed(params: {
|
||||
interaction: AgentComponentInteraction;
|
||||
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
|
||||
channelId: string;
|
||||
rawGuildId: string | undefined;
|
||||
channelCtx: DiscordChannelContext;
|
||||
memberRoleIds: string[];
|
||||
user: DiscordUser;
|
||||
replyOpts: { ephemeral?: boolean };
|
||||
componentLabel: string;
|
||||
unauthorizedReply: string;
|
||||
allowNameMatching: boolean;
|
||||
}): Promise<boolean> {
|
||||
const {
|
||||
interaction,
|
||||
guildInfo,
|
||||
channelId,
|
||||
rawGuildId,
|
||||
channelCtx,
|
||||
memberRoleIds,
|
||||
user,
|
||||
replyOpts,
|
||||
componentLabel,
|
||||
unauthorizedReply,
|
||||
} = params;
|
||||
|
||||
if (!rawGuildId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
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 } = resolveDiscordMemberAccessState({
|
||||
channelConfig,
|
||||
guildInfo,
|
||||
memberRoleIds,
|
||||
sender: {
|
||||
id: user.id,
|
||||
name: user.username,
|
||||
tag: user.discriminator ? `${user.username}#${user.discriminator}` : undefined,
|
||||
},
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
});
|
||||
if (memberAllowed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
logVerbose(`agent ${componentLabel}: blocked user ${user.id} (not in users/roles allowlist)`);
|
||||
try {
|
||||
await interaction.reply({
|
||||
content: unauthorizedReply,
|
||||
...replyOpts,
|
||||
});
|
||||
} catch {
|
||||
// Interaction may have expired
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function ensureComponentUserAllowed(params: {
|
||||
entry: DiscordComponentEntry;
|
||||
interaction: AgentComponentInteraction;
|
||||
user: DiscordUser;
|
||||
replyOpts: { ephemeral?: boolean };
|
||||
componentLabel: string;
|
||||
unauthorizedReply: string;
|
||||
allowNameMatching: boolean;
|
||||
}): Promise<boolean> {
|
||||
const allowList = normalizeDiscordAllowList(params.entry.allowedUsers, [
|
||||
"discord:",
|
||||
"user:",
|
||||
"pk:",
|
||||
]);
|
||||
if (!allowList) {
|
||||
return true;
|
||||
}
|
||||
const match = resolveDiscordAllowListMatch({
|
||||
allowList,
|
||||
candidate: {
|
||||
id: params.user.id,
|
||||
name: params.user.username,
|
||||
tag: formatDiscordUserTag(params.user),
|
||||
},
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
});
|
||||
if (match.allowed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
logVerbose(
|
||||
`discord component ${params.componentLabel}: blocked user ${params.user.id} (not in allowedUsers)`,
|
||||
);
|
||||
try {
|
||||
await params.interaction.reply({
|
||||
content: params.unauthorizedReply,
|
||||
...params.replyOpts,
|
||||
});
|
||||
} catch {
|
||||
// Interaction may have expired
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function ensureAgentComponentInteractionAllowed(params: {
|
||||
ctx: AgentComponentContext;
|
||||
interaction: AgentComponentInteraction;
|
||||
channelId: string;
|
||||
rawGuildId: string | undefined;
|
||||
memberRoleIds: string[];
|
||||
user: DiscordUser;
|
||||
replyOpts: { ephemeral?: boolean };
|
||||
componentLabel: string;
|
||||
unauthorizedReply: string;
|
||||
}): Promise<{ parentId: string | undefined } | null> {
|
||||
const guildInfo = resolveDiscordGuildEntry({
|
||||
guild: params.interaction.guild ?? undefined,
|
||||
guildId: params.rawGuildId,
|
||||
guildEntries: params.ctx.guildEntries,
|
||||
});
|
||||
const channelCtx = resolveDiscordChannelContext(params.interaction);
|
||||
const memberAllowed = await ensureGuildComponentMemberAllowed({
|
||||
interaction: params.interaction,
|
||||
guildInfo,
|
||||
channelId: params.channelId,
|
||||
rawGuildId: params.rawGuildId,
|
||||
channelCtx,
|
||||
memberRoleIds: params.memberRoleIds,
|
||||
user: params.user,
|
||||
replyOpts: params.replyOpts,
|
||||
componentLabel: params.componentLabel,
|
||||
unauthorizedReply: params.unauthorizedReply,
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig),
|
||||
});
|
||||
if (!memberAllowed) {
|
||||
return null;
|
||||
}
|
||||
return { parentId: channelCtx.parentId };
|
||||
}
|
||||
|
||||
export type AgentComponentContext = {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
discordConfig?: DiscordAccountConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
token?: string;
|
||||
guildEntries?: Record<string, DiscordGuildEntryResolved>;
|
||||
/** DM allowlist (from allowFrom config; legacy: dm.allowFrom) */
|
||||
allowFrom?: string[];
|
||||
/** DM policy (default: "pairing") */
|
||||
dmPolicy?: "open" | "pairing" | "allowlist" | "disabled";
|
||||
};
|
||||
|
||||
/**
|
||||
* Build agent button custom ID: agent:componentId=<id>
|
||||
* The channelId is NOT embedded in customId - we use interaction.rawData.channel_id instead
|
||||
* to prevent channel spoofing attacks.
|
||||
*
|
||||
* Carbon's customIdParser parses "key:arg1=value1;arg2=value2" into { arg1: value1, arg2: value2 }
|
||||
*/
|
||||
export function buildAgentButtonCustomId(componentId: string): string {
|
||||
return `${AGENT_BUTTON_KEY}:componentId=${encodeURIComponent(componentId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build agent select menu custom ID: agentsel:componentId=<id>
|
||||
*/
|
||||
export function buildAgentSelectCustomId(componentId: string): string {
|
||||
return `${AGENT_SELECT_KEY}:componentId=${encodeURIComponent(componentId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse agent component data from Carbon's parsed ComponentData
|
||||
* Supports both legacy { componentId } and Components v2 { cid } payloads.
|
||||
*/
|
||||
function readParsedComponentId(data: ComponentData): unknown {
|
||||
if (!data || typeof data !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
return "cid" in data
|
||||
? (data as Record<string, unknown>).cid
|
||||
: (data as Record<string, unknown>).componentId;
|
||||
}
|
||||
|
||||
function parseAgentComponentData(data: ComponentData): {
|
||||
componentId: string;
|
||||
} | null {
|
||||
const raw = readParsedComponentId(data);
|
||||
|
||||
const decodeSafe = (value: string): string => {
|
||||
// `cid` values may be raw (not URI-encoded). Guard against malformed % sequences.
|
||||
// Only attempt decoding when it looks like it contains percent-encoding.
|
||||
if (!value.includes("%")) {
|
||||
return value;
|
||||
}
|
||||
// If it has a % but not a valid %XX sequence, skip decode.
|
||||
if (!/%[0-9A-Fa-f]{2}/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const componentId =
|
||||
typeof raw === "string" ? decodeSafe(raw) : typeof raw === "number" ? String(raw) : null;
|
||||
|
||||
if (!componentId) {
|
||||
return null;
|
||||
}
|
||||
return { componentId };
|
||||
}
|
||||
|
||||
function formatUsername(user: { username: string; discriminator?: string | null }): string {
|
||||
if (user.discriminator && user.discriminator !== "0") {
|
||||
return `${user.username}#${user.discriminator}`;
|
||||
}
|
||||
return user.username;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a channel type is a thread type
|
||||
*/
|
||||
function isThreadChannelType(channelType: number | undefined): boolean {
|
||||
return (
|
||||
channelType === ChannelType.PublicThread ||
|
||||
channelType === ChannelType.PrivateThread ||
|
||||
channelType === ChannelType.AnnouncementThread
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureDmComponentAuthorized(params: {
|
||||
ctx: AgentComponentContext;
|
||||
interaction: AgentComponentInteraction;
|
||||
user: DiscordUser;
|
||||
componentLabel: string;
|
||||
replyOpts: { ephemeral?: boolean };
|
||||
}): Promise<boolean> {
|
||||
const { ctx, interaction, user, componentLabel, replyOpts } = params;
|
||||
const dmPolicy = ctx.dmPolicy ?? "pairing";
|
||||
if (dmPolicy === "disabled") {
|
||||
logVerbose(`agent ${componentLabel}: blocked (DM policy disabled)`);
|
||||
try {
|
||||
await interaction.reply({
|
||||
content: "DM interactions are disabled.",
|
||||
...replyOpts,
|
||||
});
|
||||
} catch {
|
||||
// Interaction may have expired
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (dmPolicy === "open") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
|
||||
provider: "discord",
|
||||
accountId: ctx.accountId,
|
||||
dmPolicy,
|
||||
});
|
||||
const effectiveAllowFrom = [...(ctx.allowFrom ?? []), ...storeAllowFrom];
|
||||
const allowList = normalizeDiscordAllowList(effectiveAllowFrom, ["discord:", "user:", "pk:"]);
|
||||
const allowMatch = allowList
|
||||
? resolveDiscordAllowListMatch({
|
||||
allowList,
|
||||
candidate: {
|
||||
id: user.id,
|
||||
name: user.username,
|
||||
tag: formatDiscordUserTag(user),
|
||||
},
|
||||
allowNameMatching: isDangerousNameMatchingEnabled(ctx.discordConfig),
|
||||
})
|
||||
: { allowed: false };
|
||||
if (allowMatch.allowed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (dmPolicy === "pairing") {
|
||||
const pairingResult = await issuePairingChallenge({
|
||||
channel: "discord",
|
||||
senderId: user.id,
|
||||
senderIdLine: `Your Discord user id: ${user.id}`,
|
||||
meta: {
|
||||
tag: formatDiscordUserTag(user),
|
||||
name: user.username,
|
||||
},
|
||||
upsertPairingRequest: async ({ id, meta }) =>
|
||||
await upsertChannelPairingRequest({
|
||||
channel: "discord",
|
||||
id,
|
||||
accountId: ctx.accountId,
|
||||
meta,
|
||||
}),
|
||||
sendPairingReply: async (text) => {
|
||||
await interaction.reply({
|
||||
content: text,
|
||||
...replyOpts,
|
||||
});
|
||||
},
|
||||
});
|
||||
if (!pairingResult.created) {
|
||||
try {
|
||||
await interaction.reply({
|
||||
content: "Pairing already requested. Ask the bot owner to approve your code.",
|
||||
...replyOpts,
|
||||
});
|
||||
} catch {
|
||||
// Interaction may have expired
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
logVerbose(`agent ${componentLabel}: blocked DM user ${user.id} (not in allowFrom)`);
|
||||
try {
|
||||
await interaction.reply({
|
||||
content: `You are not authorized to use this ${componentLabel}.`,
|
||||
...replyOpts,
|
||||
});
|
||||
} catch {
|
||||
// Interaction may have expired
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function resolveInteractionContextWithDmAuth(params: {
|
||||
ctx: AgentComponentContext;
|
||||
interaction: AgentComponentInteraction;
|
||||
label: string;
|
||||
componentLabel: string;
|
||||
defer?: boolean;
|
||||
}): Promise<ComponentInteractionContext | null> {
|
||||
const interactionCtx = await resolveComponentInteractionContext({
|
||||
interaction: params.interaction,
|
||||
label: params.label,
|
||||
defer: params.defer,
|
||||
});
|
||||
if (!interactionCtx) {
|
||||
return null;
|
||||
}
|
||||
if (interactionCtx.isDirectMessage) {
|
||||
const authorized = await ensureDmComponentAuthorized({
|
||||
ctx: params.ctx,
|
||||
interaction: params.interaction,
|
||||
user: interactionCtx.user,
|
||||
componentLabel: params.componentLabel,
|
||||
replyOpts: interactionCtx.replyOpts,
|
||||
});
|
||||
if (!authorized) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return interactionCtx;
|
||||
}
|
||||
|
||||
function normalizeComponentId(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseDiscordComponentData(
|
||||
data: ComponentData,
|
||||
customId?: string,
|
||||
): { componentId: string; modalId?: string } | null {
|
||||
if (!data || typeof data !== "object") {
|
||||
return null;
|
||||
}
|
||||
const rawComponentId = readParsedComponentId(data);
|
||||
const rawModalId =
|
||||
"mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId;
|
||||
let componentId = normalizeComponentId(rawComponentId);
|
||||
let modalId = normalizeComponentId(rawModalId);
|
||||
if (!componentId && customId) {
|
||||
const parsed = parseDiscordComponentCustomId(customId);
|
||||
if (parsed) {
|
||||
componentId = parsed.componentId;
|
||||
modalId = parsed.modalId;
|
||||
}
|
||||
}
|
||||
if (!componentId) {
|
||||
return null;
|
||||
}
|
||||
return { componentId, modalId };
|
||||
}
|
||||
|
||||
function parseDiscordModalId(data: ComponentData, customId?: string): string | null {
|
||||
if (data && typeof data === "object") {
|
||||
const rawModalId =
|
||||
"mid" in data ? (data as { mid?: unknown }).mid : (data as { modalId?: unknown }).modalId;
|
||||
const modalId = normalizeComponentId(rawModalId);
|
||||
if (modalId) {
|
||||
return modalId;
|
||||
}
|
||||
}
|
||||
if (customId) {
|
||||
return parseDiscordModalCustomId(customId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveInteractionCustomId(interaction: AgentComponentInteraction): string | undefined {
|
||||
if (!interaction?.rawData || typeof interaction.rawData !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
if (!("data" in interaction.rawData)) {
|
||||
return undefined;
|
||||
}
|
||||
const data = (interaction.rawData as { data?: { custom_id?: unknown } }).data;
|
||||
const customId = data?.custom_id;
|
||||
if (typeof customId !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const trimmed = customId.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function mapOptionLabels(
|
||||
options: Array<{ value: string; label: string }> | undefined,
|
||||
values: string[],
|
||||
) {
|
||||
if (!options || options.length === 0) {
|
||||
return values;
|
||||
}
|
||||
const map = new Map(options.map((option) => [option.value, option.label]));
|
||||
return values.map((value) => map.get(value) ?? value);
|
||||
}
|
||||
|
||||
function mapSelectValues(entry: DiscordComponentEntry, values: string[]): string[] {
|
||||
if (entry.selectType === "string") {
|
||||
return mapOptionLabels(entry.options, values);
|
||||
}
|
||||
if (entry.selectType === "user") {
|
||||
return values.map((value) => `user:${value}`);
|
||||
}
|
||||
if (entry.selectType === "role") {
|
||||
return values.map((value) => `role:${value}`);
|
||||
}
|
||||
if (entry.selectType === "mentionable") {
|
||||
return values.map((value) => `mentionable:${value}`);
|
||||
}
|
||||
if (entry.selectType === "channel") {
|
||||
return values.map((value) => `channel:${value}`);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function resolveModalFieldValues(
|
||||
field: DiscordModalEntry["fields"][number],
|
||||
interaction: ModalInteraction,
|
||||
): string[] {
|
||||
const fields = interaction.fields;
|
||||
const optionLabels = field.options?.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
}));
|
||||
const required = field.required === true;
|
||||
try {
|
||||
switch (field.type) {
|
||||
case "text": {
|
||||
const value = required ? fields.getText(field.id, true) : fields.getText(field.id);
|
||||
return value ? [value] : [];
|
||||
}
|
||||
case "select":
|
||||
case "checkbox":
|
||||
case "radio": {
|
||||
const values = required
|
||||
? fields.getStringSelect(field.id, true)
|
||||
: (fields.getStringSelect(field.id) ?? []);
|
||||
return mapOptionLabels(optionLabels, values);
|
||||
}
|
||||
case "role-select": {
|
||||
try {
|
||||
const roles = required
|
||||
? fields.getRoleSelect(field.id, true)
|
||||
: (fields.getRoleSelect(field.id) ?? []);
|
||||
return roles.map((role) => role.name ?? role.id);
|
||||
} catch {
|
||||
const values = required
|
||||
? fields.getStringSelect(field.id, true)
|
||||
: (fields.getStringSelect(field.id) ?? []);
|
||||
return values;
|
||||
}
|
||||
}
|
||||
case "user-select": {
|
||||
const users = required
|
||||
? fields.getUserSelect(field.id, true)
|
||||
: (fields.getUserSelect(field.id) ?? []);
|
||||
return users.map((user) => formatDiscordUserTag(user));
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
} catch (err) {
|
||||
logError(`agent modal: failed to read field ${field.id}: ${String(err)}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function formatModalSubmissionText(
|
||||
entry: DiscordModalEntry,
|
||||
interaction: ModalInteraction,
|
||||
): string {
|
||||
const lines: string[] = [`Form "${entry.title}" submitted.`];
|
||||
for (const field of entry.fields) {
|
||||
const values = resolveModalFieldValues(field, interaction);
|
||||
if (values.length === 0) {
|
||||
continue;
|
||||
}
|
||||
lines.push(`- ${field.label}: ${values.join(", ")}`);
|
||||
}
|
||||
if (lines.length === 1) {
|
||||
lines.push("- (no values)");
|
||||
}
|
||||
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;
|
||||
@@ -931,54 +239,6 @@ async function dispatchPluginDiscordInteractiveEvent(params: {
|
||||
return "unmatched";
|
||||
}
|
||||
|
||||
function resolveComponentCommandAuthorized(params: {
|
||||
ctx: AgentComponentContext;
|
||||
interactionCtx: ComponentInteractionContext;
|
||||
channelConfig: ReturnType<typeof resolveDiscordChannelConfigWithFallback>;
|
||||
guildInfo: ReturnType<typeof resolveDiscordGuildEntry>;
|
||||
allowNameMatching: boolean;
|
||||
}): boolean {
|
||||
const { ctx, interactionCtx, channelConfig, guildInfo } = params;
|
||||
if (interactionCtx.isDirectMessage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const { ownerAllowList, ownerAllowed: ownerOk } = resolveDiscordOwnerAccess({
|
||||
allowFrom: ctx.allowFrom,
|
||||
sender: {
|
||||
id: interactionCtx.user.id,
|
||||
name: interactionCtx.user.username,
|
||||
tag: formatDiscordUserTag(interactionCtx.user),
|
||||
},
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
});
|
||||
|
||||
const { hasAccessRestrictions, memberAllowed } = resolveDiscordMemberAccessState({
|
||||
channelConfig,
|
||||
guildInfo,
|
||||
memberRoleIds: interactionCtx.memberRoleIds,
|
||||
sender: {
|
||||
id: interactionCtx.user.id,
|
||||
name: interactionCtx.user.username,
|
||||
tag: formatDiscordUserTag(interactionCtx.user),
|
||||
},
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
});
|
||||
const useAccessGroups = ctx.cfg.commands?.useAccessGroups !== false;
|
||||
const authorizers = useAccessGroups
|
||||
? [
|
||||
{ configured: ownerAllowList != null, allowed: ownerOk },
|
||||
{ configured: hasAccessRestrictions, allowed: memberAllowed },
|
||||
]
|
||||
: [{ configured: hasAccessRestrictions, allowed: memberAllowed }];
|
||||
|
||||
return resolveCommandAuthorizedFromAuthorizers({
|
||||
useAccessGroups,
|
||||
authorizers,
|
||||
modeWhenAccessGroupsOff: "configured",
|
||||
});
|
||||
}
|
||||
|
||||
async function dispatchDiscordComponentEvent(params: {
|
||||
ctx: AgentComponentContext;
|
||||
interaction: AgentComponentInteraction;
|
||||
@@ -1045,7 +305,7 @@ async function dispatchDiscordComponentEvent(params: {
|
||||
? resolvePinnedMainDmOwnerFromAllowlist({
|
||||
dmScope: ctx.cfg.session?.dmScope,
|
||||
allowFrom: channelConfig?.users ?? guildInfo?.users,
|
||||
normalizeEntry: (entry) => {
|
||||
normalizeEntry: (entry: string) => {
|
||||
const normalized = normalizeDiscordAllowList([entry], ["discord:", "user:", "pk:"]);
|
||||
const candidate = normalized?.ids.values().next().value;
|
||||
return typeof candidate === "string" && /^\d+$/.test(candidate) ? candidate : undefined;
|
||||
|
||||
@@ -7,11 +7,11 @@ import type {
|
||||
import type { Client } from "@buape/carbon";
|
||||
import { ChannelType } from "discord-api-types/v10";
|
||||
import type { GatewayPresenceUpdate } from "discord-api-types/v10";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { buildPluginBindingApprovalCustomId } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing";
|
||||
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,
|
||||
registerDiscordComponentEntries,
|
||||
@@ -50,7 +50,6 @@ const readAllowFromStoreMock = vi.hoisted(() => vi.fn());
|
||||
const upsertPairingRequestMock = vi.hoisted(() => vi.fn());
|
||||
const enqueueSystemEventMock = vi.hoisted(() => vi.fn());
|
||||
const dispatchReplyMock = vi.hoisted(() => vi.fn());
|
||||
const deliverDiscordReplyMock = vi.hoisted(() => vi.fn());
|
||||
const recordInboundSessionMock = vi.hoisted(() => vi.fn());
|
||||
const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn());
|
||||
const resolveStorePathMock = vi.hoisted(() => vi.fn());
|
||||
@@ -59,37 +58,20 @@ 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", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/infra/system-events.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/infra/system-events.js")>();
|
||||
vi.mock("../../../../src/security/dm-policy-shared.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../../../src/security/dm-policy-shared.js")>();
|
||||
return {
|
||||
...actual,
|
||||
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
|
||||
readStoreAllowFromForDmPolicy: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", () => ({
|
||||
dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./reply-delivery.js", () => ({
|
||||
deliverDiscordReply: (...args: unknown[]) => deliverDiscordReplyMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/channels/session.js", () => ({
|
||||
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/config/sessions.js")>();
|
||||
vi.mock("../../../../src/pairing/pairing-store.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/pairing/pairing-store.js")>();
|
||||
return {
|
||||
...actual,
|
||||
readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args),
|
||||
resolveStorePath: (...args: unknown[]) => resolveStorePathMock(...args),
|
||||
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -105,6 +87,42 @@ vi.mock("../../../../src/plugins/conversation-binding.js", async (importOriginal
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/infra/system-events.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/infra/system-events.js")>();
|
||||
return {
|
||||
...actual,
|
||||
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("../../../../src/auto-reply/reply/provider-dispatcher.js")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/channels/session.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/channels/session.js")>();
|
||||
return {
|
||||
...actual,
|
||||
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/config/sessions.js")>();
|
||||
return {
|
||||
...actual,
|
||||
readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args),
|
||||
resolveStorePath: (...args: unknown[]) => resolveStorePathMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/plugins/interactive.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/plugins/interactive.js")>();
|
||||
return {
|
||||
@@ -287,12 +305,18 @@ describe("discord component interactions", () => {
|
||||
const createComponentButtonInteraction = (overrides: Partial<ButtonInteraction> = {}) => {
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const defer = vi.fn().mockResolvedValue(undefined);
|
||||
const rest = {
|
||||
get: vi.fn().mockResolvedValue({ type: ChannelType.DM }),
|
||||
post: vi.fn().mockResolvedValue({}),
|
||||
patch: vi.fn().mockResolvedValue({}),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const interaction = {
|
||||
rawData: { channel_id: "dm-channel", id: "interaction-1" },
|
||||
user: { id: "123456789", username: "AgentUser", discriminator: "0001" },
|
||||
customId: "occomp:cid=btn_1",
|
||||
message: { id: "msg-1" },
|
||||
client: { rest: {} },
|
||||
client: { rest },
|
||||
defer,
|
||||
reply,
|
||||
...overrides,
|
||||
@@ -303,6 +327,12 @@ describe("discord component interactions", () => {
|
||||
const createModalInteraction = (overrides: Partial<ModalInteraction> = {}) => {
|
||||
const reply = vi.fn().mockResolvedValue(undefined);
|
||||
const acknowledge = vi.fn().mockResolvedValue(undefined);
|
||||
const rest = {
|
||||
get: vi.fn().mockResolvedValue({ type: ChannelType.DM }),
|
||||
post: vi.fn().mockResolvedValue({}),
|
||||
patch: vi.fn().mockResolvedValue({}),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const fields = {
|
||||
getText: (key: string) => (key === "fld_1" ? "Casey" : undefined),
|
||||
getStringSelect: (_key: string) => undefined,
|
||||
@@ -316,7 +346,7 @@ describe("discord component interactions", () => {
|
||||
fields,
|
||||
acknowledge,
|
||||
reply,
|
||||
client: { rest: {} },
|
||||
client: { rest },
|
||||
...overrides,
|
||||
} as unknown as ModalInteraction;
|
||||
return { interaction, acknowledge, reply };
|
||||
@@ -363,7 +393,6 @@ describe("discord component interactions", () => {
|
||||
lastDispatchCtx = params.ctx;
|
||||
await params.dispatcherOptions.deliver({ text: "ok" });
|
||||
});
|
||||
deliverDiscordReplyMock.mockClear();
|
||||
recordInboundSessionMock.mockClear().mockResolvedValue(undefined);
|
||||
readSessionUpdatedAtMock.mockClear().mockReturnValue(undefined);
|
||||
resolveStorePathMock.mockClear().mockReturnValue("/tmp/openclaw-sessions-test.json");
|
||||
@@ -415,8 +444,6 @@ describe("discord component interactions", () => {
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓" });
|
||||
expect(lastDispatchCtx?.BodyForAgent).toBe('Clicked "Approve".');
|
||||
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-1");
|
||||
expect(resolveDiscordComponentEntry({ id: "btn_1" })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -482,8 +509,6 @@ describe("discord component interactions", () => {
|
||||
expect(lastDispatchCtx?.BodyForAgent).toContain('Form "Details" submitted.');
|
||||
expect(lastDispatchCtx?.BodyForAgent).toContain("- Name: Casey");
|
||||
expect(dispatchReplyMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverDiscordReplyMock).toHaveBeenCalledTimes(1);
|
||||
expect(deliverDiscordReplyMock.mock.calls[0]?.[0]?.replyToId).toBe("msg-2");
|
||||
expect(resolveDiscordModalEntry({ id: "mdl_1" })).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
1030
extensions/discord/src/monitor/native-command-ui.ts
Normal file
1030
extensions/discord/src/monitor/native-command-ui.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { ChannelType } from "discord-api-types/v10";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { NativeCommandSpec } from "../../../../src/auto-reply/commands-registry.js";
|
||||
import * as dispatcherModule from "../../../../src/auto-reply/reply/provider-dispatcher.js";
|
||||
import type { ChatType } from "../../../../src/channels/chat-type.js";
|
||||
import { setDefaultChannelPluginRegistryForTests } from "../../../../src/commands/channel-test-helpers.js";
|
||||
import type { OpenClawConfig } from "../../../../src/config/config.js";
|
||||
import * as pluginCommandsModule from "../../../../src/plugins/commands.js";
|
||||
import { clearPluginCommands, registerPluginCommand } from "../../../../src/plugins/commands.js";
|
||||
@@ -12,32 +12,26 @@ import {
|
||||
} from "./native-command.test-helpers.js";
|
||||
import { createNoopThreadBindingManager } from "./thread-bindings.js";
|
||||
|
||||
type ResolveConfiguredBindingRouteFn =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConfiguredBindingRoute;
|
||||
type EnsureConfiguredBindingRouteReadyFn =
|
||||
typeof import("openclaw/plugin-sdk/conversation-runtime").ensureConfiguredBindingRouteReady;
|
||||
|
||||
const persistentBindingMocks = vi.hoisted(() => ({
|
||||
resolveConfiguredAcpBindingRecord: vi.fn<ResolveConfiguredBindingRouteFn>((params) => ({
|
||||
bindingResolution: null,
|
||||
route: params.route,
|
||||
})),
|
||||
ensureConfiguredAcpBindingSession: vi.fn<EnsureConfiguredBindingRouteReadyFn>(async () => ({
|
||||
const ensureConfiguredBindingRouteReadyMock = vi.hoisted(() =>
|
||||
vi.fn<EnsureConfiguredBindingRouteReadyFn>(async () => ({
|
||||
ok: true,
|
||||
})),
|
||||
}));
|
||||
);
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredAcpBindingRecord,
|
||||
ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredAcpBindingSession,
|
||||
ensureConfiguredBindingRouteReady: (...args: unknown[]) =>
|
||||
ensureConfiguredBindingRouteReadyMock(
|
||||
...(args as Parameters<EnsureConfiguredBindingRouteReadyFn>),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
import { createDiscordNativeCommand } from "./native-command.js";
|
||||
|
||||
function createInteraction(params?: {
|
||||
channelType?: ChannelType;
|
||||
channelId?: string;
|
||||
@@ -66,7 +60,12 @@ function createConfig(): OpenClawConfig {
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
function createNativeCommand(cfg: OpenClawConfig, commandSpec: NativeCommandSpec) {
|
||||
async function loadCreateDiscordNativeCommand() {
|
||||
return (await import("./native-command.js")).createDiscordNativeCommand;
|
||||
}
|
||||
|
||||
async function createNativeCommand(cfg: OpenClawConfig, commandSpec: NativeCommandSpec) {
|
||||
const createDiscordNativeCommand = await loadCreateDiscordNativeCommand();
|
||||
return createDiscordNativeCommand({
|
||||
command: commandSpec,
|
||||
cfg,
|
||||
@@ -78,7 +77,8 @@ function createNativeCommand(cfg: OpenClawConfig, commandSpec: NativeCommandSpec
|
||||
});
|
||||
}
|
||||
|
||||
function createPluginCommand(params: { cfg: OpenClawConfig; name: string }) {
|
||||
async function createPluginCommand(params: { cfg: OpenClawConfig; name: string }) {
|
||||
const createDiscordNativeCommand = await loadCreateDiscordNativeCommand();
|
||||
return createDiscordNativeCommand({
|
||||
command: {
|
||||
name: params.name,
|
||||
@@ -119,7 +119,7 @@ async function expectPairCommandReply(params: {
|
||||
commandName: string;
|
||||
interaction: MockCommandInteraction;
|
||||
}) {
|
||||
const command = createPluginCommand({
|
||||
const command = await createPluginCommand({
|
||||
cfg: params.cfg,
|
||||
name: params.commandName,
|
||||
});
|
||||
@@ -143,150 +143,14 @@ async function expectPairCommandReply(params: {
|
||||
);
|
||||
}
|
||||
|
||||
function createStatusCommand(cfg: OpenClawConfig) {
|
||||
return createNativeCommand(cfg, {
|
||||
async function createStatusCommand(cfg: OpenClawConfig) {
|
||||
return await createNativeCommand(cfg, {
|
||||
name: "status",
|
||||
description: "Status",
|
||||
acceptsArgs: false,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveConversationFromParams(params: Parameters<ResolveConfiguredBindingRouteFn>[0]) {
|
||||
if ("conversation" in params) {
|
||||
return params.conversation;
|
||||
}
|
||||
return {
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: params.conversationId,
|
||||
...(params.parentConversationId ? { parentConversationId: params.parentConversationId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function createConfiguredBindingResolution(params: {
|
||||
conversation: ReturnType<typeof resolveConversationFromParams>;
|
||||
boundSessionKey: string;
|
||||
}) {
|
||||
const peerKind: ChatType = params.conversation.conversationId.startsWith("dm-")
|
||||
? "direct"
|
||||
: "channel";
|
||||
const configuredBinding = {
|
||||
spec: {
|
||||
channel: "discord" as const,
|
||||
accountId: params.conversation.accountId,
|
||||
conversationId: params.conversation.conversationId,
|
||||
...(params.conversation.parentConversationId
|
||||
? { parentConversationId: params.conversation.parentConversationId }
|
||||
: {}),
|
||||
agentId: "codex",
|
||||
mode: "persistent" as const,
|
||||
},
|
||||
record: {
|
||||
bindingId: `config:acp:discord:${params.conversation.accountId}:${params.conversation.conversationId}`,
|
||||
targetSessionKey: params.boundSessionKey,
|
||||
targetKind: "session" as const,
|
||||
conversation: params.conversation,
|
||||
status: "active" as const,
|
||||
boundAt: 0,
|
||||
},
|
||||
};
|
||||
return {
|
||||
conversation: params.conversation,
|
||||
compiledBinding: {
|
||||
channel: "discord" as const,
|
||||
binding: {
|
||||
type: "acp" as const,
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: params.conversation.accountId,
|
||||
peer: {
|
||||
kind: peerKind,
|
||||
id: params.conversation.conversationId,
|
||||
},
|
||||
},
|
||||
acp: {
|
||||
mode: "persistent" as const,
|
||||
},
|
||||
},
|
||||
bindingConversationId: params.conversation.conversationId,
|
||||
target: {
|
||||
conversationId: params.conversation.conversationId,
|
||||
...(params.conversation.parentConversationId
|
||||
? { parentConversationId: params.conversation.parentConversationId }
|
||||
: {}),
|
||||
},
|
||||
agentId: "codex",
|
||||
provider: {
|
||||
compileConfiguredBinding: () => ({
|
||||
conversationId: params.conversation.conversationId,
|
||||
...(params.conversation.parentConversationId
|
||||
? { parentConversationId: params.conversation.parentConversationId }
|
||||
: {}),
|
||||
}),
|
||||
matchInboundConversation: () => ({
|
||||
conversationId: params.conversation.conversationId,
|
||||
...(params.conversation.parentConversationId
|
||||
? { parentConversationId: params.conversation.parentConversationId }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
targetFactory: {
|
||||
driverId: "acp" as const,
|
||||
materialize: () => ({
|
||||
record: configuredBinding.record,
|
||||
statefulTarget: {
|
||||
kind: "stateful" as const,
|
||||
driverId: "acp",
|
||||
sessionKey: params.boundSessionKey,
|
||||
agentId: "codex",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
match: {
|
||||
conversationId: params.conversation.conversationId,
|
||||
...(params.conversation.parentConversationId
|
||||
? { parentConversationId: params.conversation.parentConversationId }
|
||||
: {}),
|
||||
},
|
||||
record: configuredBinding.record,
|
||||
statefulTarget: {
|
||||
kind: "stateful" as const,
|
||||
driverId: "acp",
|
||||
sessionKey: params.boundSessionKey,
|
||||
agentId: "codex",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setConfiguredBinding(channelId: string, boundSessionKey: string) {
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => {
|
||||
const conversation = resolveConversationFromParams(params);
|
||||
const bindingResolution = createConfiguredBindingResolution({
|
||||
conversation: {
|
||||
...conversation,
|
||||
conversationId: channelId,
|
||||
},
|
||||
boundSessionKey,
|
||||
});
|
||||
return {
|
||||
bindingResolution,
|
||||
boundSessionKey,
|
||||
boundAgentId: "codex",
|
||||
route: {
|
||||
...params.route,
|
||||
agentId: "codex",
|
||||
sessionKey: boundSessionKey,
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
};
|
||||
});
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
}
|
||||
|
||||
function createDispatchSpy() {
|
||||
return vi.spyOn(dispatcherModule, "dispatchReplyWithDispatcher").mockResolvedValue({
|
||||
counts: {
|
||||
@@ -299,26 +163,23 @@ function createDispatchSpy() {
|
||||
|
||||
function expectBoundSessionDispatch(
|
||||
dispatchSpy: ReturnType<typeof createDispatchSpy>,
|
||||
boundSessionKey: string,
|
||||
expectedPattern: RegExp,
|
||||
) {
|
||||
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
||||
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
|
||||
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
|
||||
};
|
||||
expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey);
|
||||
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
|
||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
|
||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).toHaveBeenCalledTimes(1);
|
||||
expect(dispatchCall.ctx?.SessionKey).toMatch(expectedPattern);
|
||||
expect(dispatchCall.ctx?.CommandTargetSessionKey).toMatch(expectedPattern);
|
||||
expect(ensureConfiguredBindingRouteReadyMock).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
|
||||
async function expectBoundStatusCommandDispatch(params: {
|
||||
cfg: OpenClawConfig;
|
||||
interaction: MockCommandInteraction;
|
||||
channelId: string;
|
||||
boundSessionKey: string;
|
||||
expectedPattern: RegExp;
|
||||
}) {
|
||||
const command = createStatusCommand(params.cfg);
|
||||
setConfiguredBinding(params.channelId, params.boundSessionKey);
|
||||
const command = await createStatusCommand(params.cfg);
|
||||
|
||||
vi.spyOn(pluginCommandsModule, "matchPluginCommand").mockReturnValue(null);
|
||||
const dispatchSpy = createDispatchSpy();
|
||||
@@ -327,20 +188,16 @@ async function expectBoundStatusCommandDispatch(params: {
|
||||
params.interaction as unknown,
|
||||
);
|
||||
|
||||
expectBoundSessionDispatch(dispatchSpy, params.boundSessionKey);
|
||||
expectBoundSessionDispatch(dispatchSpy, params.expectedPattern);
|
||||
}
|
||||
|
||||
describe("Discord native plugin command dispatch", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
clearPluginCommands();
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockReset();
|
||||
persistentBindingMocks.resolveConfiguredAcpBindingRecord.mockImplementation((params) => ({
|
||||
bindingResolution: null,
|
||||
route: params.route,
|
||||
}));
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockReset();
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
setDefaultChannelPluginRegistryForTests();
|
||||
ensureConfiguredBindingRouteReadyMock.mockReset();
|
||||
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
|
||||
ok: true,
|
||||
});
|
||||
});
|
||||
@@ -397,15 +254,7 @@ describe("Discord native plugin command dispatch", () => {
|
||||
description: "Pair",
|
||||
acceptsArgs: true,
|
||||
};
|
||||
const command = createDiscordNativeCommand({
|
||||
command: commandSpec,
|
||||
cfg,
|
||||
discordConfig: cfg.channels?.discord ?? {},
|
||||
accountId: "default",
|
||||
sessionPrefix: "discord:slash",
|
||||
ephemeralDefault: true,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
});
|
||||
const command = await createNativeCommand(cfg, commandSpec);
|
||||
const interaction = createInteraction({
|
||||
channelType: ChannelType.GuildText,
|
||||
channelId: "234567890123456789",
|
||||
@@ -449,15 +298,7 @@ describe("Discord native plugin command dispatch", () => {
|
||||
description: "List cron jobs",
|
||||
acceptsArgs: false,
|
||||
};
|
||||
const command = createDiscordNativeCommand({
|
||||
command: commandSpec,
|
||||
cfg,
|
||||
discordConfig: cfg.channels?.discord ?? {},
|
||||
accountId: "default",
|
||||
sessionPrefix: "discord:slash",
|
||||
ephemeralDefault: true,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
});
|
||||
const command = await createNativeCommand(cfg, commandSpec);
|
||||
const interaction = createInteraction();
|
||||
const pluginMatch = {
|
||||
command: {
|
||||
@@ -492,11 +333,21 @@ describe("Discord native plugin command dispatch", () => {
|
||||
it("routes native slash commands through configured ACP Discord channel bindings", async () => {
|
||||
const guildId = "1459246755253325866";
|
||||
const channelId = "1478836151241412759";
|
||||
const boundSessionKey = "agent:codex:acp:binding:discord:default:feedface";
|
||||
const cfg = {
|
||||
commands: {
|
||||
useAccessGroups: false,
|
||||
},
|
||||
channels: {
|
||||
discord: {
|
||||
guilds: {
|
||||
[guildId]: {
|
||||
channels: {
|
||||
[channelId]: { allow: true, requireMention: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
@@ -522,8 +373,7 @@ describe("Discord native plugin command dispatch", () => {
|
||||
await expectBoundStatusCommandDispatch({
|
||||
cfg,
|
||||
interaction,
|
||||
channelId,
|
||||
boundSessionKey,
|
||||
expectedPattern: /^agent:codex:acp:binding:discord:default:/,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -557,7 +407,7 @@ describe("Discord native plugin command dispatch", () => {
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const command = createStatusCommand(cfg);
|
||||
const command = await createStatusCommand(cfg);
|
||||
const interaction = createInteraction({
|
||||
channelType: ChannelType.GuildText,
|
||||
channelId,
|
||||
@@ -578,13 +428,11 @@ describe("Discord native plugin command dispatch", () => {
|
||||
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(
|
||||
"agent:qwen:discord:channel:1478836151241412759",
|
||||
);
|
||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
|
||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
|
||||
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes Discord DM native slash commands through configured ACP bindings", async () => {
|
||||
const channelId = "dm-1";
|
||||
const boundSessionKey = "agent:codex:acp:binding:discord:default:dmfeedface";
|
||||
const cfg = {
|
||||
commands: {
|
||||
useAccessGroups: false,
|
||||
@@ -617,15 +465,13 @@ describe("Discord native plugin command dispatch", () => {
|
||||
await expectBoundStatusCommandDispatch({
|
||||
cfg,
|
||||
interaction,
|
||||
channelId,
|
||||
boundSessionKey,
|
||||
expectedPattern: /^agent:codex:acp:binding:discord:default:/,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows recovery commands through configured ACP bindings even when ensure fails", async () => {
|
||||
const guildId = "1459246755253325866";
|
||||
const channelId = "1479098716916023408";
|
||||
const boundSessionKey = "agent:codex:acp:binding:discord:default:feedface";
|
||||
const cfg = {
|
||||
commands: {
|
||||
useAccessGroups: false,
|
||||
@@ -651,14 +497,13 @@ describe("Discord native plugin command dispatch", () => {
|
||||
guildId,
|
||||
guildName: "Ops",
|
||||
});
|
||||
const command = createNativeCommand(cfg, {
|
||||
const command = await createNativeCommand(cfg, {
|
||||
name: "new",
|
||||
description: "Start a new session.",
|
||||
acceptsArgs: true,
|
||||
});
|
||||
|
||||
setConfiguredBinding(channelId, boundSessionKey);
|
||||
persistentBindingMocks.ensureConfiguredAcpBindingSession.mockResolvedValue({
|
||||
ensureConfiguredBindingRouteReadyMock.mockResolvedValue({
|
||||
ok: false,
|
||||
error: "acpx exited with code 1",
|
||||
});
|
||||
@@ -671,10 +516,11 @@ describe("Discord native plugin command dispatch", () => {
|
||||
const dispatchCall = dispatchSpy.mock.calls[0]?.[0] as {
|
||||
ctx?: { SessionKey?: string; CommandTargetSessionKey?: string };
|
||||
};
|
||||
expect(dispatchCall.ctx?.SessionKey).toBe(boundSessionKey);
|
||||
expect(dispatchCall.ctx?.CommandTargetSessionKey).toBe(boundSessionKey);
|
||||
expect(persistentBindingMocks.resolveConfiguredAcpBindingRecord).toHaveBeenCalledTimes(1);
|
||||
expect(persistentBindingMocks.ensureConfiguredAcpBindingSession).not.toHaveBeenCalled();
|
||||
expect(dispatchCall.ctx?.SessionKey).toMatch(/^agent:codex:acp:binding:discord:default:/);
|
||||
expect(dispatchCall.ctx?.CommandTargetSessionKey).toMatch(
|
||||
/^agent:codex:acp:binding:discord:default:/,
|
||||
);
|
||||
expect(ensureConfiguredBindingRouteReadyMock).not.toHaveBeenCalled();
|
||||
expect(interaction.reply).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: "Configured ACP binding is unavailable right now. Please try again.",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,6 @@ import {
|
||||
baseRuntime,
|
||||
getFirstDiscordMessageHandlerParams,
|
||||
getProviderMonitorTestMocks,
|
||||
mockResolvedDiscordAccountConfig,
|
||||
resetDiscordProviderMonitorMocks,
|
||||
} from "../../../../test/helpers/extensions/discord-provider.test-support.js";
|
||||
|
||||
@@ -37,6 +36,21 @@ const {
|
||||
voiceRuntimeModuleLoadedMock,
|
||||
} = getProviderMonitorTestMocks();
|
||||
|
||||
function createConfigWithDiscordAccount(overrides: Record<string, unknown> = {}): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
discord: {
|
||||
accounts: {
|
||||
default: {
|
||||
token: "MTIz.abc.def",
|
||||
...overrides,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/plugin-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/plugin-runtime")>(
|
||||
"openclaw/plugin-sdk/plugin-runtime",
|
||||
@@ -90,7 +104,18 @@ describe("monitorDiscordProvider", () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
resetDiscordProviderMonitorMocks();
|
||||
vi.doMock("../accounts.js", () => ({
|
||||
resolveDiscordAccount: (...args: Parameters<typeof resolveDiscordAccountMock>) =>
|
||||
resolveDiscordAccountMock(...args),
|
||||
}));
|
||||
vi.doMock("../probe.js", () => ({
|
||||
fetchDiscordApplicationId: async () => "app-1",
|
||||
}));
|
||||
vi.doMock("../token.js", () => ({
|
||||
normalizeDiscordToken: (value?: string) => value,
|
||||
}));
|
||||
});
|
||||
|
||||
it("stops thread bindings when startup fails before lifecycle begins", async () => {
|
||||
@@ -139,7 +164,7 @@ describe("monitorDiscordProvider", () => {
|
||||
it("loads the Discord voice runtime only when voice is enabled", async () => {
|
||||
resolveDiscordAccountMock.mockReturnValue({
|
||||
accountId: "default",
|
||||
token: "cfg-token",
|
||||
token: "MTIz.abc.def",
|
||||
config: {
|
||||
commands: { native: true, nativeSkills: false },
|
||||
voice: { enabled: true },
|
||||
@@ -356,11 +381,18 @@ describe("monitorDiscordProvider", () => {
|
||||
});
|
||||
|
||||
it("forwards custom eventQueue config from discord config to Carbon Client", async () => {
|
||||
const { monitorDiscordProvider } = await import("./provider.js");
|
||||
|
||||
mockResolvedDiscordAccountConfig({
|
||||
eventQueue: { listenerTimeout: 300_000 },
|
||||
resolveDiscordAccountMock.mockReturnValue({
|
||||
accountId: "default",
|
||||
token: "MTIz.abc.def",
|
||||
config: {
|
||||
commands: { native: true, nativeSkills: false },
|
||||
voice: { enabled: false },
|
||||
agentComponents: { enabled: false },
|
||||
execApprovals: { enabled: false },
|
||||
eventQueue: { listenerTimeout: 300_000 },
|
||||
},
|
||||
});
|
||||
const { monitorDiscordProvider } = await import("./provider.js");
|
||||
|
||||
await monitorDiscordProvider({
|
||||
config: baseConfig(),
|
||||
@@ -374,12 +406,10 @@ describe("monitorDiscordProvider", () => {
|
||||
it("does not reuse eventQueue.listenerTimeout as the queued inbound worker timeout", async () => {
|
||||
const { monitorDiscordProvider } = await import("./provider.js");
|
||||
|
||||
mockResolvedDiscordAccountConfig({
|
||||
eventQueue: { listenerTimeout: 50_000 },
|
||||
});
|
||||
|
||||
await monitorDiscordProvider({
|
||||
config: baseConfig(),
|
||||
config: createConfigWithDiscordAccount({
|
||||
eventQueue: { listenerTimeout: 50_000 },
|
||||
}),
|
||||
runtime: baseRuntime(),
|
||||
});
|
||||
|
||||
@@ -392,11 +422,18 @@ describe("monitorDiscordProvider", () => {
|
||||
});
|
||||
|
||||
it("forwards inbound worker timeout config to the Discord message handler", async () => {
|
||||
const { monitorDiscordProvider } = await import("./provider.js");
|
||||
|
||||
mockResolvedDiscordAccountConfig({
|
||||
inboundWorker: { runTimeoutMs: 300_000 },
|
||||
resolveDiscordAccountMock.mockReturnValue({
|
||||
accountId: "default",
|
||||
token: "MTIz.abc.def",
|
||||
config: {
|
||||
commands: { native: true, nativeSkills: false },
|
||||
voice: { enabled: false },
|
||||
agentComponents: { enabled: false },
|
||||
execApprovals: { enabled: false },
|
||||
inboundWorker: { runTimeoutMs: 300_000 },
|
||||
},
|
||||
});
|
||||
const { monitorDiscordProvider } = await import("./provider.js");
|
||||
|
||||
await monitorDiscordProvider({
|
||||
config: baseConfig(),
|
||||
|
||||
55
extensions/discord/src/runtime-api.ts
Normal file
55
extensions/discord/src/runtime-api.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export {
|
||||
buildComputedAccountStatusSnapshot,
|
||||
buildTokenChannelStatusSummary,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
projectCredentialSnapshotFields,
|
||||
resolveConfiguredFromCredentialStatuses,
|
||||
} from "openclaw/plugin-sdk/discord";
|
||||
export {
|
||||
buildChannelConfigSchema,
|
||||
getChatChannelMeta,
|
||||
jsonResult,
|
||||
readNumberParam,
|
||||
readStringArrayParam,
|
||||
readStringParam,
|
||||
type ActionGate,
|
||||
type ChannelPlugin,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/discord-core";
|
||||
export { DiscordConfigSchema } from "openclaw/plugin-sdk/discord-core";
|
||||
export { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
|
||||
export {
|
||||
listDiscordDirectoryGroupsFromConfig,
|
||||
listDiscordDirectoryPeersFromConfig,
|
||||
} from "./directory-config.js";
|
||||
export {
|
||||
createHybridChannelConfigAdapter,
|
||||
createScopedChannelConfigAdapter,
|
||||
createScopedAccountConfigAccessors,
|
||||
createScopedChannelConfigBase,
|
||||
createTopLevelChannelConfigAdapter,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
export {
|
||||
createAccountActionGate,
|
||||
createAccountListHelpers,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
resolveAccountEntry,
|
||||
} from "openclaw/plugin-sdk/account-resolution";
|
||||
export type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
} from "openclaw/plugin-sdk/channel-runtime";
|
||||
export {
|
||||
assertMediaNotDataUrl,
|
||||
parseAvailableTags,
|
||||
readReactionParams,
|
||||
resolvePollMaxSelections,
|
||||
withNormalizedTimestamp,
|
||||
} from "openclaw/plugin-sdk/discord-core";
|
||||
export type { DiscordAccountConfig, DiscordActionConfig } from "openclaw/plugin-sdk/discord";
|
||||
export {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
createAccountScopedAllowFromSection,
|
||||
createAccountScopedGroupAccessSection,
|
||||
createLegacyCompatChannelDmPolicy,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
createEnvPatchedAccountSetupAdapter,
|
||||
noteChannelLookupFailure,
|
||||
noteChannelLookupSummary,
|
||||
parseMentionOrPrefixedId,
|
||||
patchChannelConfigForAccount,
|
||||
setLegacyChannelDmPolicyWithAllowFrom,
|
||||
setSetupChannelEnabled,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
@@ -88,21 +88,11 @@ export function createDiscordSetupWizardBase(handlers: {
|
||||
NonNullable<NonNullable<ChannelSetupWizard["groupAccess"]>["resolveAllowlist"]>
|
||||
>;
|
||||
}) {
|
||||
const discordDmPolicy: ChannelSetupDmPolicy = {
|
||||
const discordDmPolicy: ChannelSetupDmPolicy = createLegacyCompatChannelDmPolicy({
|
||||
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: handlers.promptAllowFrom,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
channel,
|
||||
@@ -145,7 +135,8 @@ export function createDiscordSetupWizardBase(handlers: {
|
||||
},
|
||||
},
|
||||
],
|
||||
groupAccess: {
|
||||
groupAccess: createAccountScopedGroupAccessSection({
|
||||
channel,
|
||||
label: "Discord channels",
|
||||
placeholder: "My Server/#general, guildId/channelId, #support",
|
||||
currentPolicy: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId: string }) =>
|
||||
@@ -164,57 +155,8 @@ export function createDiscordSetupWizardBase(handlers: {
|
||||
),
|
||||
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> };
|
||||
}) => {
|
||||
try {
|
||||
return await handlers.resolveGroupAllowlist({
|
||||
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 }));
|
||||
}
|
||||
},
|
||||
resolveAllowlist: handlers.resolveGroupAllowlist,
|
||||
fallbackResolved: (entries) => entries.map((input) => ({ input, resolved: false })),
|
||||
applyAllowlist: ({
|
||||
cfg,
|
||||
accountId,
|
||||
@@ -224,8 +166,9 @@ export function createDiscordSetupWizardBase(handlers: {
|
||||
accountId: string;
|
||||
resolved: unknown;
|
||||
}) => setDiscordGuildChannelAllowlist(cfg, accountId, resolved as never),
|
||||
},
|
||||
allowFrom: {
|
||||
}),
|
||||
allowFrom: createAccountScopedAllowFromSection({
|
||||
channel,
|
||||
credentialInputKey: "token",
|
||||
helpTitle: "Discord allowlist",
|
||||
helpLines: [
|
||||
@@ -242,42 +185,15 @@ export function createDiscordSetupWizardBase(handlers: {
|
||||
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[];
|
||||
}) => await handlers.resolveAllowFromEntries({ cfg, accountId, credentialValues, entries }),
|
||||
apply: async ({
|
||||
cfg,
|
||||
accountId,
|
||||
allowFrom,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
allowFrom: string[];
|
||||
}) =>
|
||||
patchChannelConfigForAccount({
|
||||
cfg,
|
||||
channel,
|
||||
accountId,
|
||||
patch: { dmPolicy: "allowlist", allowFrom },
|
||||
}),
|
||||
},
|
||||
resolveEntries: handlers.resolveAllowFromEntries,
|
||||
}),
|
||||
dmPolicy: discordDmPolicy,
|
||||
disable: (cfg: OpenClawConfig) => setSetupChannelEnabled(cfg, channel, false),
|
||||
} satisfies ChannelSetupWizard;
|
||||
}
|
||||
export function createDiscordSetupWizardProxy(
|
||||
loadWizard: () => Promise<{ discordSetupWizard: ChannelSetupWizard }>,
|
||||
) {
|
||||
export function createDiscordSetupWizardProxy(loadWizard: () => Promise<ChannelSetupWizard>) {
|
||||
return createAllowlistSetupWizardProxy({
|
||||
loadWizard: async () => (await loadWizard()).discordSetupWizard,
|
||||
loadWizard,
|
||||
createBase: createDiscordSetupWizardBase,
|
||||
fallbackResolvedGroupAllowlist: (entries) =>
|
||||
entries.map((input) => ({ input, resolved: false })),
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import {
|
||||
resolveEntriesWithOptionalToken,
|
||||
type OpenClawConfig,
|
||||
promptLegacyChannelAllowFrom,
|
||||
resolveSetupAccountId,
|
||||
promptLegacyChannelAllowFromForAccount,
|
||||
type WizardPrompter,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
|
||||
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
|
||||
import { resolveDefaultDiscordAccountId, resolveDiscordAccount } from "./accounts.js";
|
||||
import { normalizeDiscordSlug } from "./monitor/allow-list.js";
|
||||
import {
|
||||
resolveDiscordChannelAllowlist,
|
||||
type DiscordChannelResolution,
|
||||
} from "./resolve-channels.js";
|
||||
import { resolveDiscordChannelAllowlist } from "./resolve-channels.js";
|
||||
import { resolveDiscordUserAllowlist } from "./resolve-users.js";
|
||||
import {
|
||||
createDiscordSetupWizardBase,
|
||||
@@ -23,22 +19,26 @@ import {
|
||||
const channel = "discord" as const;
|
||||
|
||||
async function resolveDiscordAllowFromEntries(params: { token?: string; entries: string[] }) {
|
||||
if (!params.token?.trim()) {
|
||||
return params.entries.map((input) => ({
|
||||
return await resolveEntriesWithOptionalToken({
|
||||
token: params.token,
|
||||
entries: params.entries,
|
||||
buildWithoutToken: (input) => ({
|
||||
input,
|
||||
resolved: false,
|
||||
id: null,
|
||||
}));
|
||||
}
|
||||
const resolved = await resolveDiscordUserAllowlist({
|
||||
token: params.token,
|
||||
entries: params.entries,
|
||||
}),
|
||||
resolveEntries: async ({ token, entries }) =>
|
||||
(
|
||||
await resolveDiscordUserAllowlist({
|
||||
token,
|
||||
entries,
|
||||
})
|
||||
).map((entry) => ({
|
||||
input: entry.input,
|
||||
resolved: entry.resolved,
|
||||
id: entry.id ?? null,
|
||||
})),
|
||||
});
|
||||
return resolved.map((entry) => ({
|
||||
input: entry.input,
|
||||
resolved: entry.resolved,
|
||||
id: entry.id ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async function promptDiscordAllowFrom(params: {
|
||||
@@ -46,17 +46,15 @@ async function promptDiscordAllowFrom(params: {
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const accountId = resolveSetupAccountId({
|
||||
accountId: params.accountId,
|
||||
defaultAccountId: resolveDefaultDiscordAccountId(params.cfg),
|
||||
});
|
||||
const resolved = resolveDiscordAccount({ cfg: params.cfg, accountId });
|
||||
return promptLegacyChannelAllowFrom({
|
||||
return await promptLegacyChannelAllowFromForAccount({
|
||||
cfg: params.cfg,
|
||||
channel,
|
||||
prompter: params.prompter,
|
||||
existing: resolved.config.allowFrom ?? resolved.config.dm?.allowFrom ?? [],
|
||||
token: resolved.token,
|
||||
accountId: params.accountId,
|
||||
defaultAccountId: resolveDefaultDiscordAccountId(params.cfg),
|
||||
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
|
||||
resolveExisting: (account) => account.config.allowFrom ?? account.config.dm?.allowFrom ?? [],
|
||||
resolveToken: (account) => account.token,
|
||||
noteTitle: "Discord allowlist",
|
||||
noteLines: [
|
||||
"Allowlist Discord DMs by username (we resolve to user ids).",
|
||||
@@ -71,11 +69,17 @@ async function promptDiscordAllowFrom(params: {
|
||||
placeholder: "@alice, 123456789012345678",
|
||||
parseId: parseDiscordAllowFromId,
|
||||
invalidWithoutTokenNote: "Bot token missing; use numeric user ids (or mention form) only.",
|
||||
resolveEntries: ({ token, entries }) =>
|
||||
resolveDiscordUserAllowlist({
|
||||
token,
|
||||
entries,
|
||||
}),
|
||||
resolveEntries: async ({ token, entries }) =>
|
||||
(
|
||||
await resolveDiscordUserAllowlist({
|
||||
token,
|
||||
entries,
|
||||
})
|
||||
).map((entry) => ({
|
||||
input: entry.input,
|
||||
resolved: entry.resolved,
|
||||
id: entry.id ?? null,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,18 +89,20 @@ async function resolveDiscordGroupAllowlist(params: {
|
||||
credentialValues: { token?: string };
|
||||
entries: string[];
|
||||
}) {
|
||||
const token =
|
||||
resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }).token ||
|
||||
(typeof params.credentialValues.token === "string" ? params.credentialValues.token : "");
|
||||
if (!token || params.entries.length === 0) {
|
||||
return params.entries.map((input) => ({
|
||||
return await resolveEntriesWithOptionalToken({
|
||||
token:
|
||||
resolveDiscordAccount({ cfg: params.cfg, accountId: params.accountId }).token ||
|
||||
(typeof params.credentialValues.token === "string" ? params.credentialValues.token : ""),
|
||||
entries: params.entries,
|
||||
buildWithoutToken: (input) => ({
|
||||
input,
|
||||
resolved: false,
|
||||
}));
|
||||
}
|
||||
return await resolveDiscordChannelAllowlist({
|
||||
token,
|
||||
entries: params.entries,
|
||||
}),
|
||||
resolveEntries: async ({ token, entries }) =>
|
||||
await resolveDiscordChannelAllowlist({
|
||||
token,
|
||||
entries,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
|
||||
import {
|
||||
createScopedAccountConfigAccessors,
|
||||
createScopedChannelConfigBase,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
DiscordConfigSchema,
|
||||
getChatChannelMeta,
|
||||
type ChannelPlugin,
|
||||
} from "openclaw/plugin-sdk/discord-core";
|
||||
import { createChannelPluginBase } from "openclaw/plugin-sdk/core";
|
||||
import { inspectDiscordAccount } from "./account-inspect.js";
|
||||
import {
|
||||
listDiscordAccountIds,
|
||||
@@ -16,6 +7,13 @@ import {
|
||||
resolveDiscordAccount,
|
||||
type ResolvedDiscordAccount,
|
||||
} from "./accounts.js";
|
||||
import {
|
||||
createScopedChannelConfigAdapter,
|
||||
buildChannelConfigSchema,
|
||||
DiscordConfigSchema,
|
||||
getChatChannelMeta,
|
||||
type ChannelPlugin,
|
||||
} from "./runtime-api.js";
|
||||
import { createDiscordSetupWizardProxy } from "./setup-core.js";
|
||||
|
||||
export const DISCORD_CHANNEL = "discord" as const;
|
||||
@@ -24,24 +22,20 @@ async function loadDiscordChannelRuntime() {
|
||||
return await import("./channel.runtime.js");
|
||||
}
|
||||
|
||||
export const discordSetupWizard = createDiscordSetupWizardProxy(async () => ({
|
||||
discordSetupWizard: (await loadDiscordChannelRuntime()).discordSetupWizard,
|
||||
}));
|
||||
export const discordSetupWizard = createDiscordSetupWizardProxy(
|
||||
async () => (await loadDiscordChannelRuntime()).discordSetupWizard,
|
||||
);
|
||||
|
||||
export const discordConfigAccessors = createScopedAccountConfigAccessors({
|
||||
resolveAccount: ({ cfg, accountId }) => resolveDiscordAccount({ cfg, accountId }),
|
||||
resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom,
|
||||
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
|
||||
resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo,
|
||||
});
|
||||
|
||||
export const discordConfigBase = createScopedChannelConfigBase<ResolvedDiscordAccount>({
|
||||
export const discordConfigAdapter = createScopedChannelConfigAdapter<ResolvedDiscordAccount>({
|
||||
sectionKey: DISCORD_CHANNEL,
|
||||
listAccountIds: listDiscordAccountIds,
|
||||
resolveAccount: (cfg, accountId) => resolveDiscordAccount({ cfg, accountId }),
|
||||
inspectAccount: (cfg, accountId) => inspectDiscordAccount({ cfg, accountId }),
|
||||
defaultAccountId: resolveDefaultDiscordAccountId,
|
||||
clearBaseFields: ["token", "name"],
|
||||
resolveAllowFrom: (account: ResolvedDiscordAccount) => account.config.dm?.allowFrom,
|
||||
formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }),
|
||||
resolveDefaultTo: (account: ResolvedDiscordAccount) => account.config.defaultTo,
|
||||
});
|
||||
|
||||
export function createDiscordPluginBase(params: {
|
||||
@@ -58,12 +52,10 @@ export function createDiscordPluginBase(params: {
|
||||
| "config"
|
||||
| "setup"
|
||||
> {
|
||||
return {
|
||||
return createChannelPluginBase({
|
||||
id: DISCORD_CHANNEL,
|
||||
meta: {
|
||||
...getChatChannelMeta(DISCORD_CHANNEL),
|
||||
},
|
||||
setupWizard: discordSetupWizard,
|
||||
meta: { ...getChatChannelMeta(DISCORD_CHANNEL) },
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "channel", "thread"],
|
||||
polls: true,
|
||||
@@ -78,7 +70,7 @@ export function createDiscordPluginBase(params: {
|
||||
reload: { configPrefixes: ["channels.discord"] },
|
||||
configSchema: buildChannelConfigSchema(DiscordConfigSchema),
|
||||
config: {
|
||||
...discordConfigBase,
|
||||
...discordConfigAdapter,
|
||||
isConfigured: (account) => Boolean(account.token?.trim()),
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
@@ -87,8 +79,18 @@ export function createDiscordPluginBase(params: {
|
||||
configured: Boolean(account.token?.trim()),
|
||||
tokenSource: account.tokenSource,
|
||||
}),
|
||||
...discordConfigAccessors,
|
||||
},
|
||||
setup: params.setup,
|
||||
};
|
||||
}) as Pick<
|
||||
ChannelPlugin<ResolvedDiscordAccount>,
|
||||
| "id"
|
||||
| "meta"
|
||||
| "setupWizard"
|
||||
| "capabilities"
|
||||
| "streaming"
|
||||
| "reload"
|
||||
| "configSchema"
|
||||
| "config"
|
||||
| "setup"
|
||||
>;
|
||||
}
|
||||
|
||||
44
extensions/fal/index.ts
Normal file
44
extensions/fal/index.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/core";
|
||||
import { buildFalImageGenerationProvider } from "openclaw/plugin-sdk/image-generation";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { applyFalConfig, FAL_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js";
|
||||
|
||||
const PROVIDER_ID = "fal";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
name: "fal Provider",
|
||||
description: "Bundled fal image generation provider",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: "fal",
|
||||
docsPath: "/providers/models",
|
||||
envVars: ["FAL_KEY"],
|
||||
auth: [
|
||||
createProviderApiKeyAuthMethod({
|
||||
providerId: PROVIDER_ID,
|
||||
methodId: "api-key",
|
||||
label: "fal API key",
|
||||
hint: "Image generation API key",
|
||||
optionKey: "falApiKey",
|
||||
flagName: "--fal-api-key",
|
||||
envVar: "FAL_KEY",
|
||||
promptMessage: "Enter fal API key",
|
||||
defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF,
|
||||
expectedProviders: ["fal"],
|
||||
applyConfig: (cfg) => applyFalConfig(cfg),
|
||||
wizard: {
|
||||
choiceId: "fal-api-key",
|
||||
choiceLabel: "fal API key",
|
||||
choiceHint: "Image generation API key",
|
||||
groupId: "fal",
|
||||
groupLabel: "fal",
|
||||
groupHint: "Image generation",
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
api.registerImageGenerationProvider(buildFalImageGenerationProvider());
|
||||
},
|
||||
});
|
||||
21
extensions/fal/onboard.ts
Normal file
21
extensions/fal/onboard.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-onboard";
|
||||
|
||||
export const FAL_DEFAULT_IMAGE_MODEL_REF = "fal/fal-ai/flux/dev";
|
||||
|
||||
export function applyFalConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
if (cfg.agents?.defaults?.imageGenerationModel) {
|
||||
return cfg;
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
agents: {
|
||||
...cfg.agents,
|
||||
defaults: {
|
||||
...cfg.agents?.defaults,
|
||||
imageGenerationModel: {
|
||||
primary: FAL_DEFAULT_IMAGE_MODEL_REF,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
27
extensions/fal/openclaw.plugin.json
Normal file
27
extensions/fal/openclaw.plugin.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"id": "fal",
|
||||
"providers": ["fal"],
|
||||
"providerAuthEnvVars": {
|
||||
"fal": ["FAL_KEY"]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "fal",
|
||||
"method": "api-key",
|
||||
"choiceId": "fal-api-key",
|
||||
"choiceLabel": "fal API key",
|
||||
"groupId": "fal",
|
||||
"groupLabel": "fal",
|
||||
"groupHint": "Image generation",
|
||||
"optionKey": "falApiKey",
|
||||
"cliFlag": "--fal-api-key",
|
||||
"cliOption": "--fal-api-key <key>",
|
||||
"cliDescription": "fal API key"
|
||||
}
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user