From 4a2a6008091d60d7a543c2ea53a3306d2ea2c1ec Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:57:42 +1000 Subject: [PATCH] feat(channels): add channel-owned setup contracts (#112176) * feat(channels): add channel-owned setup contracts * test(channels): align legacy setup fixtures * chore(channels): regenerate config and SDK baselines after rebase * fix(update): run fresh doctor after current-process core changes * fix(channels): align add pre-scan with execution precedence * style(cli): format channels-cli test additions * fix(channels): restore option-before-positional channel resolution via metadata arity scan * fix(channels): keep help flags out of metadata arity escalation * test(update): mock fresh post-update doctor in current-process suites * style: format review fixes and correct entrypoint mock type * fix(channels): register only modern contract options for dual-publishing plugins * test(update): align downgrade suites with fresh-doctor child invocation * docs(channels): record empty-contract and input-forwarding invariants * fix(line): keep the shipped --token switch as a channel access token alias * fix(signal): stop treating exact cross-family loopback endpoints as bind-aligned * chore(config): regenerate docs config baselines after second rebase * style: format rebased channels add tests * fix(channels): enforce field-key and flag-name agreement in setup contracts * fix(signal): detect container endpoints for bare --http-url setup * fix(signal): ignore unconfigured accounts in transport collision checks * fix(channels): validate negated setup flags in contract and normalizer * fix(signal): preserve existing transport kind when setup detection is unreachable * style(signal): use direct boolean check in collision guard * style(signal): type test config literals * docs(update): record two-read design of fresh-doctor validation gate * fix(channels): satisfy post-rebase architecture gates * docs: refresh channel setup map --------- Co-authored-by: Peter Steinberger --- docs/.generated/config-baseline.counts.json | 2 +- docs/.generated/config-baseline.sha256 | 4 +- .../.generated/plugin-sdk-api-baseline.sha256 | 10 +- docs/channels/signal.md | 91 +- docs/cli/channels.md | 12 +- docs/docs_map.md | 3 +- docs/plugins/sdk-setup.md | 58 ++ docs/plugins/sdk-subpaths.md | 4 +- docs/reference/rpc.md | 2 +- docs/reference/wizard.md | 2 +- docs/start/wizard-cli-reference.md | 2 +- extensions/clickclack/package.json | 27 +- extensions/clickclack/src/channel.setup.ts | 3 +- extensions/clickclack/src/channel.ts | 3 +- extensions/clickclack/src/setup-core.ts | 54 +- extensions/discord/package.json | 15 + extensions/discord/src/channel.setup.ts | 3 +- extensions/discord/src/channel.ts | 3 +- extensions/discord/src/setup-adapter.ts | 16 + extensions/discord/src/shared.ts | 3 + extensions/feishu/package.json | 3 +- extensions/feishu/src/channel.ts | 3 +- extensions/feishu/src/setup-core.ts | 6 + extensions/googlechat/package.json | 60 +- extensions/googlechat/src/channel-base.ts | 4 +- extensions/googlechat/src/setup-core.ts | 38 + extensions/imessage/package.json | 43 +- extensions/imessage/src/channel.setup.ts | 3 +- extensions/imessage/src/channel.ts | 3 +- extensions/imessage/src/setup-core.ts | 28 +- extensions/imessage/src/shared.ts | 3 + extensions/irc/package.json | 17 +- extensions/irc/src/channel.ts | 3 +- extensions/irc/src/setup-core.ts | 31 +- extensions/line/package.json | 14 +- extensions/line/src/channel.setup.ts | 3 +- extensions/line/src/channel.ts | 3 +- extensions/line/src/setup-core.test.ts | 30 + extensions/line/src/setup-core.ts | 50 +- extensions/matrix/package.json | 41 +- extensions/matrix/src/channel.setup.ts | 7 +- extensions/matrix/src/channel.ts | 12 +- extensions/matrix/src/setup-core.ts | 46 + extensions/mattermost/package.json | 16 +- extensions/mattermost/src/channel.setup.ts | 3 +- extensions/mattermost/src/channel.ts | 3 +- extensions/mattermost/src/setup-core.ts | 30 +- extensions/msteams/package.json | 3 +- extensions/msteams/src/channel.setup.ts | 3 +- extensions/msteams/src/channel.ts | 3 +- extensions/msteams/src/setup-core.ts | 6 + extensions/nextcloud-talk/package.json | 32 +- extensions/nextcloud-talk/src/channel.ts | 3 +- extensions/nextcloud-talk/src/setup-core.ts | 49 +- extensions/nostr/package.json | 17 +- extensions/nostr/src/channel.setup.ts | 4 +- extensions/nostr/src/channel.ts | 3 +- extensions/nostr/src/setup-adapter.ts | 27 +- extensions/nostr/src/setup-surface.ts | 8 +- extensions/qa-channel/package.json | 11 +- extensions/qa-channel/src/channel-base.ts | 37 +- .../qa-lab/src/crabline-transport.test.ts | 17 +- extensions/qa-lab/src/crabline-transport.ts | 37 +- extensions/qqbot/package.json | 7 +- extensions/qqbot/src/bridge/config-shared.ts | 21 + extensions/qqbot/src/channel.setup.ts | 8 +- extensions/qqbot/src/channel.ts | 8 +- extensions/raft/package.json | 11 +- extensions/raft/src/channel.ts | 1 + extensions/raft/src/setup.ts | 12 + extensions/reef/package.json | 3 +- extensions/reef/src/channel.ts | 3 +- extensions/reef/src/setup.ts | 6 + extensions/signal/api.ts | 8 + extensions/signal/doctor-contract-api.test.ts | 769 ++++++++++++++++- extensions/signal/doctor-contract-api.ts | 55 +- extensions/signal/package.json | 75 +- extensions/signal/src/account-types.ts | 9 +- extensions/signal/src/accounts.test.ts | 440 +++++++++- extensions/signal/src/accounts.ts | 224 ++++- extensions/signal/src/channel.setup.ts | 4 +- extensions/signal/src/channel.ts | 6 +- extensions/signal/src/client-adapter.test.ts | 707 +++------------- extensions/signal/src/client-adapter.ts | 192 +---- extensions/signal/src/config-compat.ts | 743 ++++++++++++++++ extensions/signal/src/config-schema.test.ts | 164 +++- extensions/signal/src/config-schema.ts | 157 +++- extensions/signal/src/config-ui-hints.ts | 14 +- extensions/signal/src/core.test.ts | 82 +- extensions/signal/src/doctor.ts | 12 + .../src/monitor.tool-result.autostart.test.ts | 51 +- ...-only-senders-uuid-allowlist-entry.test.ts | 15 +- .../src/monitor.tool-result.test-harness.ts | 36 +- extensions/signal/src/monitor.ts | 42 +- extensions/signal/src/probe.ts | 44 +- extensions/signal/src/runtime-api.ts | 5 +- extensions/signal/src/send-reactions.test.ts | 27 +- extensions/signal/src/send-reactions.ts | 6 +- extensions/signal/src/send.test.ts | 47 +- extensions/signal/src/send.ts | 17 +- extensions/signal/src/setup-core.test.ts | 559 ++++++++++++ extensions/signal/src/setup-core.ts | 290 ++++++- extensions/signal/src/setup-surface.ts | 77 +- extensions/signal/src/setup-transport.test.ts | 801 ++++++++++++++++++ extensions/signal/src/setup-transport.ts | 322 +++++++ extensions/signal/src/shared.ts | 16 +- extensions/signal/src/sse-reconnect.ts | 12 +- .../signal/src/transport-detection.runtime.ts | 2 + extensions/signal/src/transport-detection.ts | 53 ++ .../signal/src/transport-policy.test.ts | 37 + extensions/signal/src/transport-policy.ts | 127 +++ .../signal/src/transport-probes.runtime.ts | 3 + extensions/signal/src/transport-url.ts | 57 ++ extensions/slack/package.json | 19 +- extensions/slack/src/channel.setup.ts | 7 +- extensions/slack/src/channel.ts | 7 +- extensions/slack/src/setup-core.ts | 45 +- extensions/slack/src/shared.ts | 3 + extensions/sms/package.json | 13 +- extensions/sms/src/channel.ts | 51 +- extensions/synology-chat/package.json | 12 +- extensions/synology-chat/src/channel.ts | 7 +- extensions/synology-chat/src/setup-surface.ts | 24 + extensions/telegram/package.json | 21 + extensions/telegram/src/channel.setup.ts | 3 +- extensions/telegram/src/channel.ts | 3 +- extensions/telegram/src/setup-core.ts | 21 + extensions/telegram/src/shared.ts | 3 + extensions/tlon/package.json | 44 +- extensions/tlon/src/channel.ts | 3 +- extensions/tlon/src/setup-core.ts | 41 + extensions/twitch/package.json | 3 +- extensions/twitch/src/plugin.ts | 3 +- extensions/twitch/src/setup-surface.ts | 13 +- extensions/whatsapp/package.json | 18 +- extensions/whatsapp/src/channel.setup.ts | 3 +- extensions/whatsapp/src/channel.ts | 3 +- extensions/whatsapp/src/setup-core.ts | 11 + extensions/whatsapp/src/shared.ts | 2 + extensions/zalo/package.json | 23 +- extensions/zalo/src/channel.ts | 3 +- extensions/zalo/src/setup-core.ts | 21 + extensions/zalouser/package.json | 3 +- extensions/zalouser/src/channel.setup.ts | 3 +- extensions/zalouser/src/channel.ts | 7 +- extensions/zalouser/src/setup-core.ts | 6 + extensions/zalouser/src/shared.ts | 3 + .../official-external-channel-catalog.json | 4 + scripts/plugin-sdk-surface-report.mjs | 6 +- src/channels/plugins/cli-add-options.ts | 64 +- .../contracts/plugin-shape.contract.test.ts | 15 + src/channels/plugins/setup-contract.test.ts | 235 +++++ src/channels/plugins/setup-contract.ts | 429 ++++++++++ src/channels/plugins/setup-helpers.ts | 84 +- src/channels/plugins/setup-input.ts | 62 ++ src/channels/plugins/setup-wizard-types.ts | 2 + src/channels/plugins/setup-wizard.ts | 17 +- src/channels/plugins/types.adapters.ts | 20 +- src/channels/plugins/types.core.ts | 64 +- src/channels/plugins/types.plugin.ts | 4 + src/cli/channels-cli-add-args.ts | 147 ++++ src/cli/channels-cli.test.ts | 455 +++++++++- src/cli/channels-cli.ts | 169 ++-- src/cli/update-cli.test.ts | 340 ++++++++ .../update-cli/update-command-fresh-doctor.ts | 228 +++++ .../update-cli/update-command-post-core.ts | 85 +- .../update-command-post-plugin-validation.ts | 30 + .../update-cli/update-command-post-update.ts | 29 +- src/cli/update-cli/update-command-resume.ts | 12 +- src/cli/update-cli/update-command.test.ts | 45 +- src/commands/channels.add.test.ts | 163 +++- src/commands/channels/add-mutators.ts | 11 +- src/commands/channels/add.ts | 40 +- ...ndled-channel-config-metadata.generated.ts | 16 +- src/config/channel-configured.test.ts | 2 +- src/config/types.signal.ts | 52 +- src/plugin-sdk/channel-setup.ts | 3 +- src/plugin-sdk/core.ts | 10 +- .../manifest-registry-installed.test.ts | 65 +- src/plugins/manifest-registry-installed.ts | 77 ++ src/plugins/manifest.ts | 6 + 181 files changed, 9189 insertions(+), 1721 deletions(-) create mode 100644 extensions/line/src/setup-core.test.ts create mode 100644 extensions/signal/src/config-compat.ts create mode 100644 extensions/signal/src/doctor.ts create mode 100644 extensions/signal/src/setup-core.test.ts create mode 100644 extensions/signal/src/setup-transport.test.ts create mode 100644 extensions/signal/src/setup-transport.ts create mode 100644 extensions/signal/src/transport-detection.runtime.ts create mode 100644 extensions/signal/src/transport-detection.ts create mode 100644 extensions/signal/src/transport-policy.test.ts create mode 100644 extensions/signal/src/transport-policy.ts create mode 100644 extensions/signal/src/transport-probes.runtime.ts create mode 100644 extensions/signal/src/transport-url.ts create mode 100644 src/channels/plugins/setup-contract.test.ts create mode 100644 src/channels/plugins/setup-contract.ts create mode 100644 src/channels/plugins/setup-input.ts create mode 100644 src/cli/channels-cli-add-args.ts create mode 100644 src/cli/update-cli/update-command-fresh-doctor.ts create mode 100644 src/cli/update-cli/update-command-post-plugin-validation.ts diff --git a/docs/.generated/config-baseline.counts.json b/docs/.generated/config-baseline.counts.json index 3f7324dbbae8..a3408a55a62a 100644 --- a/docs/.generated/config-baseline.counts.json +++ b/docs/.generated/config-baseline.counts.json @@ -1,5 +1,5 @@ { "core": 2303, - "channel": 3627, + "channel": 3632, "plugin": 3556 } diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 113da374c251..26f7edc4f937 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -c98d7150aae3d5b6f9b50a1bdbc479bc1b2e20f8713d0b32ceef1f32ee9fb185 config-baseline.json +a774472fe53ef058fa7c5252fc12811fa2e99a5a54bc37cdd4bbd82556365932 config-baseline.json 663339158a0bace1ebda4d5f84a74c7d8b40b3a911c1f3fcbb16f730c9af37bd config-baseline.core.json -d8a79905c6191dfb9391c16afd33cf9ac573691d26b3b8cc635e19fd7f2ae316 config-baseline.channel.json +8f64ad7f21d2d1aa18a956243f1a67c86f6cfa3fe2cddbf3ebfafb263ea02092 config-baseline.channel.json 28460228b14a94a2b93040ab3f43b214bdc6d4fda4139a75b21e70fc5983dd56 config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index f5241e86ccd5..bb3e58f340cd 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -24,7 +24,7 @@ ad60ccc4fe9084d47f0477e02d9296bacad32f26d7456e2a84be8d25a53a25c2 module/boolean 16dc9d32e8ca3ef78fc63e0fc4b20e6b943633e9572de1a49d24a880b6ffc66c module/channel-config-primitives f80effbeef28fefc635e8cdc85fcc9d9d453325cd0478b7db5914abe48582bd4 module/channel-config-schema 2dd98659d9600e755f09ef00dd91c36692b8562ed3700461937e94f6cd1e640a module/channel-contract -a6bc51efd3ce23e65432c9a6790670e2a0acb96ae7263fedbaed52ea353228be module/channel-core +82a966cf4c343179eea76afdcc982cd814a80048b3cbee95844dcddb64a949dd module/channel-core fbf353eb38ae68d8ded3f2a60b432c7bb2c245d2ec7e7c9f53c6da19a0db0938 module/channel-entry-contract 982f29a18e07228e3da82cae67d06ff38249592a29c2fd28f01f0d2016ff80d9 module/channel-feedback d1df68d6c5773da6113f8b237dc75316ec1fb878c807cb00d86070aadadccabf module/channel-inbound @@ -42,7 +42,7 @@ bba5540be7cf9613a163663decdb2affe2af9bbd3ad7914989ab186f9c2abec1 module/channel 17cec26bc71fc43a066049ef63f95bf29737113c26ab13689ceff602b9aa11d6 module/channel-secret-basic-runtime 0ceb4378709eb2d92a62a275f87fa04e18f77df8f942a9a0acef81019ebc1e24 module/channel-secret-runtime 7c90157a95bc0523fc66b1f78ce140f7ec7dbf809dfa3a480244efc01f972754 module/channel-send-result -e705b97daac4fbd7f6aeb2e515b1cdaa1db342684644eaef8197767b2595826e module/channel-setup +fa58237f4af17c2a3444eb01475e7267d86f37ffc8bab6ce243143a35908bbfd module/channel-setup 47719805c7a1d2623dcecafad50abb6cc674262b250e1f0d7020a79ad41cfd42 module/channel-status a4918b82b795f086ae71acb43e2b725fda01f6c8bf2333c155a52581bd4cf8aa module/channel-streaming c89ec1b194b76f67a6f4dd108dccf460da6065646cba31374c8aa748f23a39e4 module/collection-runtime @@ -55,7 +55,7 @@ eb4c757fe0086c1dbfa4c3f3caf3dcff0d3cab3924c608237f08f740a6ee5f59 module/command 20f3f8042de53e4eee61b64de9102c8c202b9299e6a29235647a4729f70145f2 module/config-mutation 189fa5a240cad0404cd281ad0a14a105a8f3231278d87b71cfbc4f96fb8e48ef module/config-runtime c1ea9510dfda047609a99d5d2cd1f1560f5d469a36e6b695766213d695c25b0f module/conversation-runtime -d9267aacc65aeebf0046d4eef691e5a510764063e6c7942b1d6bc7a282ca67a1 module/core +4f44038ff66f5945b58a6816e9e1a2e1103b50e14215da8c3e5c95cc064d2fbf module/core 4af19d59c2f18674e7d7f7dc1b358b644dc707e6bd601dc47168bd9e4a669940 module/dedupe-runtime f70c93d28053ca2e8353e45e6515ce7acef188097c6117d1545965d0699c8004 module/device-bootstrap 6215d3af5923bf5a616d73062534968b69f448e3e30adc64ae9caebdd1a46d71 module/diagnostic-runtime @@ -117,8 +117,8 @@ b6b8edc50ecab8386c9acd8f374a207212b5a99c8f518538bbcf0c458dda3881 module/runtime 596a315d426121c9620b314e3a9a7f523840b46e007d94d0d5e83cdedf789d15 module/security-runtime 31b785e74f1f8f56241b7756ef6a5d86199c5ce177cbb1c234a261866972f270 module/session-discussion 9d7d884330397701c7de9f5b6800b970b2b349027491704184cb1bd6cda1fd00 module/session-store-runtime -37ea3cf6f50cd6beff7d950c20f17f6e77568ed4344c1c03b190ba85bbb6396b module/setup -14ac7b747c723b38dbd9951640f8c4be57d3487c35bf3e7b56581af70cfbde1f module/setup-runtime +7cad408673562b0ff33f60071698bea2c39d02c64f4f235851c5c2ceb946910d module/setup +3901204978c50eb5908ac504a9cc1060fb8baa32dad27f6caed7583ca9737a89 module/setup-runtime cd431f6ba8327b81438b7a63b1963120f200f5abd145fb6aa7c5c561339cb0b1 module/setup-tools 18e384ec43d9eaee52c8e286e127bda2048370e2337964a754d94b236724ca9e module/skill-commands-runtime ae469f32799380e6b045abaefefee6eb3f00d714ffbf36b6eeef5025dc529472 module/speech-settings diff --git a/docs/channels/signal.md b/docs/channels/signal.md index 8967146d0c53..61fd6eb090ce 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -63,7 +63,10 @@ Minimal config: signal: { enabled: true, account: "+15551234567", - cliPath: "signal-cli", + transport: { + kind: "managed-native", + cliPath: "signal-cli", + }, dmPolicy: "pairing", allowFrom: ["+15557654321"], }, @@ -71,15 +74,14 @@ Minimal config: } ``` -| Field | Description | -| ------------ | ------------------------------------------------- | -| `account` | Bot phone number in E.164 format (`+15551234567`) | -| `cliPath` | Path to `signal-cli` (`signal-cli` if on `PATH`) | -| `configPath` | signal-cli config dir passed as `--config` | -| `dmPolicy` | DM access policy (`pairing` recommended) | -| `allowFrom` | Phone numbers or `uuid:` values allowed to DM | +| Field | Description | +| ----------- | ------------------------------------------------- | +| `account` | Bot phone number in E.164 format (`+15551234567`) | +| `transport` | Account-owned Signal connection and process mode | +| `dmPolicy` | DM access policy (`pairing` recommended) | +| `allowFrom` | Phone numbers or `uuid:` values allowed to DM | -Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [Multi-account channels](/gateway/config-channels#multi-account-all-channels) for the shared pattern. +Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. Each named account owns its `transport`; it does not inherit the top-level transport. The top-level transport belongs only to the implicit `default` account. See [Multi-account channels](/gateway/config-channels#multi-account-all-channels) for the shared pattern. ## What it is @@ -154,27 +156,41 @@ Upstream references: - Captcha flow: `https://github.com/AsamK/signal-cli/wiki/Registration-with-captcha` - Linking flow: `https://github.com/AsamK/signal-cli/wiki/Linking-other-devices-(Provisioning)` -## External daemon mode (httpUrl) +## External native daemon mode To manage `signal-cli` yourself (slow JVM cold starts, container init, shared CPUs), run the daemon separately and point OpenClaw at it: +For non-interactive setup, select the endpoint kind explicitly when needed: + +```bash +openclaw channels add --channel signal --signal-number +15551234567 \ + --http-url http://127.0.0.1:8080 --signal-transport external-native +``` + ```json5 { channels: { signal: { - httpUrl: "http://127.0.0.1:8080", - autoStart: false, + transport: { + kind: "external-native", + url: "http://127.0.0.1:8080", + }, }, }, } ``` -This skips auto-spawn and OpenClaw's startup wait. For slow auto-spawned starts, set `channels.signal.startupTimeoutMs`. +This skips auto-spawn and OpenClaw's startup wait. For a managed daemon with a slow start, set `channels.signal.transport.startupTimeoutMs`. ## Container mode (bbernhard/signal-cli-rest-api) Instead of running `signal-cli` natively, use the [bbernhard/signal-cli-rest-api](https://github.com/bbernhard/signal-cli-rest-api) Docker container, which wraps `signal-cli` behind a REST + WebSocket interface. +```bash +openclaw channels add --channel signal --signal-number +15551234567 \ + --http-url http://signal-cli:8080 --signal-transport container +``` + Requirements: - The container **must** run with `MODE=json-rpc` for real-time message receiving. @@ -201,31 +217,31 @@ OpenClaw config: signal: { enabled: true, account: "+15551234567", - httpUrl: "http://signal-cli:8080", - autoStart: false, - apiMode: "container", // or "auto" to detect automatically + transport: { + kind: "container", + url: "http://signal-cli:8080", + }, }, }, } ``` -`apiMode` controls which protocol OpenClaw uses: +`transport.kind` controls which protocol and process lifecycle OpenClaw uses: -| Value | Behavior | -| ------------- | ------------------------------------------------------------------------------------ | -| `"auto"` | (Default) Probes both transports; streaming validates container WebSocket receive | -| `"native"` | Force native signal-cli (JSON-RPC at `/api/v1/rpc`, SSE at `/api/v1/events`) | -| `"container"` | Force bbernhard container (REST at `/v2/send`, WebSocket at `/v1/receive/{account}`) | +| Value | Behavior | +| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `"managed-native"` | Start native signal-cli and use JSON-RPC at `/api/v1/rpc` plus SSE at `/api/v1/events`; `url` may select a connection endpoint distinct from the daemon bind | +| `"external-native"` | Connect to an already-running native signal-cli daemon | +| `"container"` | Connect to bbernhard REST at `/v2/send` and WebSocket at `/v1/receive/{account}` | -When `apiMode` is `"auto"`, OpenClaw caches the detected mode for 30 seconds per daemon URL to avoid repeated probes (native wins when both transports are healthy). Container receive is only selected for streaming after `/v1/receive/{account}` upgrades to WebSocket, which requires `MODE=json-rpc`. +Setup and `openclaw doctor --fix` may probe an existing endpoint once to identify its concrete kind. Runtime operations do not auto-detect or switch protocols. Container mode supports the same Signal operations as native mode where the container exposes matching APIs: sends, receives, attachments, typing indicators, read/viewed receipts, reactions, groups, and styled text. OpenClaw translates native Signal RPC calls into the container's REST payloads, including `group.{base64(internal_id)}` group IDs and `text_mode: "styled"` for formatted text. Operational notes: -- Use `autoStart: false` with container mode; OpenClaw should not spawn a native daemon when `apiMode: "container"` is selected. -- Use `MODE=json-rpc` for receiving. `MODE=normal` can make `/v1/about` look healthy, but `/v1/receive/{account}` will not WebSocket-upgrade, so OpenClaw will not select container receive streaming in `auto` mode. -- Set `apiMode: "container"` when `httpUrl` points at the bbernhard REST API, `"native"` when it points at native `signal-cli` JSON-RPC/SSE, and `"auto"` when the deployment may vary. +- Use `MODE=json-rpc` for receiving. `MODE=normal` can make `/v1/about` look healthy, but `/v1/receive/{account}` will not WebSocket-upgrade, so container receive streaming will fail its probe. +- Set `kind: "container"` for the bbernhard REST API and `kind: "external-native"` for native `signal-cli` JSON-RPC/SSE. - Container attachment downloads honor the same media byte limits as native mode. Oversized responses are rejected before being fully buffered when the server sends `Content-Length`, and while streaming otherwise. ## Access control (DMs + groups) @@ -290,7 +306,7 @@ Allowed group messages that do not mention the bot stay silent and are kept only - Attachments are supported (base64 fetched from `signal-cli`). - Voice-note attachments use the `signal-cli` filename as a MIME fallback when `contentType` is missing, so audio transcription can still classify AAC voice memos. - Default media cap: `channels.signal.mediaMaxMb` (default 8). -- Use `channels.signal.ignoreAttachments` to skip downloading media. +- Use `channels.signal.ignoreAttachments` to skip downloading media for any transport. - Group history context uses `channels.signal.historyLimit` (or `channels.signal.accounts.*.historyLimit`), falling back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50). ## Typing + read receipts @@ -418,7 +434,7 @@ openclaw pairing list signal Common failures: -- Daemon reachable but no replies: verify account/daemon settings (`httpUrl`, `account`) and receive mode. +- Daemon reachable but no replies: verify `account`, `transport.kind`, the transport URL, and receive mode. - DMs ignored: sender is pending pairing approval. - Group messages ignored: group sender/mention gating blocks delivery. - Config validation errors after edits: run `openclaw doctor --fix`. @@ -448,17 +464,18 @@ Full configuration: [Configuration](/gateway/configuration) Provider options: - `channels.signal.enabled`: enable/disable channel startup. -- `channels.signal.apiMode`: `auto | native | container` (default: auto). See [Container mode](#container-mode-bbernhardsignal-cli-rest-api). - `channels.signal.account`: E.164 for the bot account. - `channels.signal.accountUuid`: optional bot account UUID for native @mention detection and loop protection. -- `channels.signal.cliPath`: path to `signal-cli`. -- `channels.signal.configPath`: optional `signal-cli --config` directory. -- `channels.signal.httpUrl`: full daemon URL and canonical daemon bind (default `http://127.0.0.1:8080`). -- `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset). -- `channels.signal.startupTimeoutMs`: startup wait timeout in ms (min 1000, cap 120000; default 30000). -- `channels.signal.receiveMode`: `on-start | manual`. -- `channels.signal.ignoreAttachments`: skip attachment downloads. -- `channels.signal.ignoreStories`: ignore stories from the daemon. +- `channels.signal.transport`: account-owned transport. Omit it for managed native defaults. +- `channels.signal.transport.kind`: `managed-native | external-native | container`. +- `channels.signal.transport.url`: required for `external-native` and `container`; optional for `managed-native` when its connection endpoint differs from the daemon bind. +- `channels.signal.transport.cliPath`: managed-native path to `signal-cli`. +- `channels.signal.transport.configPath`: optional managed-native `signal-cli --config` directory. +- `channels.signal.transport.httpHost`, `channels.signal.transport.httpPort`: managed-native daemon bind (default `127.0.0.1:8080`). +- `channels.signal.transport.startupTimeoutMs`: managed-native startup wait in ms (min 1000, cap 120000; default 30000). +- `channels.signal.transport.receiveMode`: managed-native `on-start | manual`. +- `channels.signal.ignoreAttachments`: skip inbound attachment downloads for this account. +- `channels.signal.transport.ignoreStories`: managed-native story toggle. - `channels.signal.sendReadReceipts`: forward read receipts. - `channels.signal.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing). - `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:`). `open` requires `"*"`. Signal has no usernames; use phone/UUID IDs. diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 43eeee8d577c..81c4f178dfd4 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -80,13 +80,17 @@ openclaw channels remove --channel telegram --delete ``` -`openclaw channels add --help` shows per-channel flags (token, private key, app token, signal-cli paths, etc). +`openclaw channels add telegram --help` or `openclaw channels add --channel telegram --help` shows only Telegram's setup flags. `openclaw channels add --help` shows only the shared command envelope. `channels remove` only operates on installed/configured channel plugins. Use `channels add` first for installable catalog channels. Without `--delete` it asks to disable the account and keeps its config; `--delete` removes the config entries without prompting. For runtime-backed channel plugins, `channels remove` also asks the running Gateway to stop the selected account before it updates config, so disabling or deleting an account does not leave the old listener active until restart. -Core-owned non-interactive add flags are `--account `, `--name `, `--token`, `--token-file`, and `--use-env` (env-backed auth, default account only, where supported). Channel plugins contribute their own setup flags, including `--bot-token`, `--app-token`, `--secret`, `--secret-file`, `--password`, `--cli-path`, `--url`, `--base-url`, `--workspace`, `--http-url`, and `--auth-dir`. Channel-specific flags include: +The shared control envelope contains only `--channel`, `--account`, and the optional account display `--name`. Each modern channel plugin owns its credential, transport, and provider-specific semantics. Once a channel is selected by positional id or `--channel `, the CLI builds only that channel's options from bundled or installed plugin package metadata without loading channel runtime code. + +Common-looking flags such as `--token`, `--url`, or `--use-env` are still channel-owned when a modern contract handles them. When a selected third-party plugin still uses the legacy shared setup adapter, core registers the released compatibility flag set for that channel only, alongside its legacy `cliAddOptions`. Unrelated legacy fields do not leak into other channels, and a modern selected channel rejects compatibility flags it did not declare. + +Examples of channel-owned flags include: | Channel | Flags | | ----------- | ---------------------------------------------------------------------------------------------------- | @@ -94,12 +98,14 @@ Core-owned non-interactive add flags are `--account `, `--name `, `--t | iMessage | `--cli-path`, `--db-path`, `--service`, `--region` | | Matrix | `--homeserver`, `--user-id`, `--access-token`, `--password`, `--device-name`, `--initial-sync-limit` | | Nostr | `--private-key`, `--relay-urls` | -| Signal | `--signal-number`, `--cli-path`, `--http-url`, `--http-host`, `--http-port` | +| Signal | `--signal-number`, `--signal-transport`, `--cli-path`, `--http-url`, `--http-host`, `--http-port` | | Tlon | `--ship`, `--url`, `--code`, `--group-channels`, `--dm-allowlist`, `--auto-discover-channels` | | WhatsApp | `--auth-dir` | If a channel plugin needs to be installed during a flag-driven add command, OpenClaw uses the channel's default install source without opening the interactive plugin install prompt. +Both guided setup and flag-driven setup pass through the selected channel's parser, validation, account resolution, config writer, and post-write hooks. Unsupported flags fail with the owning channel's setup error instead of being accepted through a global input bag. + When you run `openclaw channels add` with no direct account, credential, or channel-config flags, the interactive wizard can prompt. A positional channel id and `--channel ` both preselect that channel without bypassing guidance: ```bash diff --git a/docs/docs_map.md b/docs/docs_map.md index bdb16a1fa11e..d2ad98f80f5b 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -859,7 +859,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: What it is - H2: Setup path A: link existing Signal account (QR) - H2: Setup path B: register dedicated bot number (SMS, Linux) - - H2: External daemon mode (httpUrl) + - H2: External native daemon mode - H2: Container mode (bbernhard/signal-cli-rest-api) - H2: Access control (DMs + groups) - H2: How it works (behavior) @@ -7632,6 +7632,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Package metadata - H3: openclaw fields - H3: openclaw.channel + - H3: Channel-owned setup fields - H3: openclaw.install - H3: Deferred full load - H2: Plugin manifest diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md index 2995f14dd413..b8bc8b1a23db 100644 --- a/docs/plugins/sdk-setup.md +++ b/docs/plugins/sdk-setup.md @@ -107,6 +107,63 @@ Provider ids (`providers: string[]`) are manifest metadata, not package metadata `openclaw.channel` is cheap package metadata for channel discovery and setup surfaces before runtime loads. +### Channel-owned setup fields + +Channel plugins should define setup fields once in runtime code with `defineChannelSetupContract(...)` and publish the matching serializable projection under `openclaw.channel.setup.fields`. The runtime definition infers the plugin-local input type, parses both guided and non-interactive values, and keeps channel-specific keys out of core types. Package metadata lets `openclaw channels add --help` and `openclaw channels add --channel --help` discover only the selected channel's options without loading the plugin. + +```ts +import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; + +export const setupContract = defineChannelSetupContract({ + fields: { + endpoint: { + kind: "string", + cli: { flags: "--endpoint ", description: "Service endpoint" }, + }, + transport: { + kind: "choice", + choices: ["native", "container"], + cli: { flags: "--transport ", description: "Transport owner" }, + }, + }, + adapter: { + applyAccountConfig: ({ cfg, input }) => ({ + ...cfg, + channels: { ...cfg.channels, example: input }, + }), + }, +}); +``` + +```json +{ + "openclaw": { + "channel": { + "id": "example", + "setup": { + "fields": [ + { + "key": "endpoint", + "kind": "string", + "cli": { "flags": "--endpoint ", "description": "Service endpoint" } + }, + { + "key": "transport", + "kind": "choice", + "choices": ["native", "container"], + "cli": { "flags": "--transport ", "description": "Transport owner" } + } + ] + } + } + } +} +``` + +Supported field kinds are `string`, `boolean`, `integer`, `string-list`, and `choice`. Use `sensitive: true` for credentials. Each field key must equal the camelCased attribute name of its long CLI flag, including any negated form, such as `apiToken` for `--api-token`. Boolean fields may add `cli.negatedFlags` when both positive and `--no-*` forms are needed. `channel`, `account`, and the account display `name` remain the shared control envelope. + +The released `setup`/`ChannelSetupInput` adapter stays available for existing external plugins. New plugins should expose `setupContract`; OpenClaw always prefers it when both are present. + | Field | Type | What it means | | -------------------------------------- | ---------- | ----------------------------------------------------------------------------- | | `id` | `string` | Canonical channel id. | @@ -128,6 +185,7 @@ Provider ids (`providers: string[]`) are manifest metadata, not package metadata | `quickstartAllowFrom` | `boolean` | Opt this channel into the standard quickstart `allowFrom` setup flow. | | `forceAccountBinding` | `boolean` | Require explicit account binding even when only one account exists. | | `preferSessionLookupForAnnounceTarget` | `boolean` | Prefer session lookup when resolving announce targets for this channel. | +| `setup` | `object` | Serializable channel-owned setup fields used for lazy CLI option discovery. | Example: diff --git a/docs/plugins/sdk-subpaths.md b/docs/plugins/sdk-subpaths.md index 6a80e8951df6..b931b52e8669 100644 --- a/docs/plugins/sdk-subpaths.md +++ b/docs/plugins/sdk-subpaths.md @@ -76,9 +76,9 @@ deprecated for new code; see the per-row notes below. | --- | --- | | `plugin-sdk/channel-core` | `defineChannelPluginEntry`, `defineSetupPluginEntry`, `createChatChannelPlugin`, `createChannelPluginBase`, `createChannelConfigUiHints` | | `plugin-sdk/json-schema-runtime` | Private-local after July 2026; Cached JSON Schema validation helper for plugin-owned schemas | - | `plugin-sdk/channel-setup` | `createOptionalChannelSetupSurface`, `createOptionalChannelSetupAdapter`, `createOptionalChannelSetupWizard`, plus `DEFAULT_ACCOUNT_ID`, `createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, `splitSetupEntries` | + | `plugin-sdk/channel-setup` | `defineChannelSetupContract`, channel-owned setup field/input types, `createOptionalChannelSetupSurface`, `createOptionalChannelSetupAdapter`, `createOptionalChannelSetupWizard`, plus `DEFAULT_ACCOUNT_ID`, `createTopLevelChannelDmPolicy`, `setSetupChannelEnabled`, `splitSetupEntries` | | `plugin-sdk/setup` | Shared setup wizard helpers, setup translator, allowlist prompts, setup status builders | - | `plugin-sdk/setup-runtime` | `createSetupTranslator`, `createPatchedAccountSetupAdapter`, `createEnvPatchedAccountSetupAdapter`, `createSetupInputPresenceValidator`, `noteChannelLookupFailure`, `noteChannelLookupSummary`, `promptResolvedAllowFrom`, `splitSetupEntries`, `createAllowlistSetupWizardProxy`, `createDelegatedSetupWizardProxy` | + | `plugin-sdk/setup-runtime` | `defineChannelSetupContract`, `createSetupTranslator`, `createPatchedAccountSetupAdapter`, `createEnvPatchedAccountSetupAdapter`, `createSetupInputPresenceValidator`, `noteChannelLookupFailure`, `noteChannelLookupSummary`, `promptResolvedAllowFrom`, `splitSetupEntries`, `createAllowlistSetupWizardProxy`, `createDelegatedSetupWizardProxy` | | `plugin-sdk/setup-tools` | `formatCliCommand`, `detectBinary`, `extractArchive`, `resolveBrewExecutable`, `formatDocsLink`, `CONFIG_DIR` | | `plugin-sdk/account-core` | Multi-account config/action-gate helpers, default-account fallback helpers | | `plugin-sdk/account-id` | `DEFAULT_ACCOUNT_ID`, account-id normalization helpers | diff --git a/docs/reference/rpc.md b/docs/reference/rpc.md index 4c367dfdc228..df33c5b5316a 100644 --- a/docs/reference/rpc.md +++ b/docs/reference/rpc.md @@ -13,7 +13,7 @@ OpenClaw integrates external CLIs via JSON-RPC. Two patterns are used today. - `signal-cli` runs as a daemon with JSON-RPC over HTTP. - Event stream is SSE (`/api/v1/events`). - Health probe: `/api/v1/check`. -- OpenClaw owns lifecycle when `channels.signal.autoStart=true`. +- OpenClaw owns lifecycle when `channels.signal.transport.kind="managed-native"` (the default). See [Signal](/channels/signal) for setup and endpoints. diff --git a/docs/reference/wizard.md b/docs/reference/wizard.md index 79156d4e88ef..9d87eaa283c5 100644 --- a/docs/reference/wizard.md +++ b/docs/reference/wizard.md @@ -227,7 +227,7 @@ Onboarding detects whether `signal-cli` is on `PATH` and, if missing, offers to - Linux x86-64: downloads the official native GraalVM build from the `signal-cli` GitHub releases and stores it under `~/.openclaw/tools/signal-cli//`. - macOS and other architectures: installs via Homebrew instead. - Native Windows: not supported yet; run onboarding inside WSL2 to get the Linux install path. -- Writes `channels.signal.cliPath` to your config either way. +- Writes `channels.signal.transport.cliPath` with `kind: "managed-native"` either way. ## What the wizard writes diff --git a/docs/start/wizard-cli-reference.md b/docs/start/wizard-cli-reference.md index 6310d4714d50..0d7c5fd93b6e 100644 --- a/docs/start/wizard-cli-reference.md +++ b/docs/start/wizard-cli-reference.md @@ -387,7 +387,7 @@ Clients (macOS app and Control UI) can render steps without re-implementing onbo - Downloads the appropriate release asset from the official `signal-cli` GitHub releases (native build, Linux x86-64 only) - On other platforms (macOS, non-x64 Linux), installs via Homebrew instead - Stores the release-asset install under `~/.openclaw/tools/signal-cli//` -- Writes `channels.signal.cliPath` in config +- Writes `channels.signal.transport.cliPath` with `kind: "managed-native"` in config - Native Windows is not supported yet; run onboarding inside WSL2 to get the Linux install path ## Related docs diff --git a/extensions/clickclack/package.json b/extensions/clickclack/package.json index 96db224f4af2..b13000461d92 100644 --- a/extensions/clickclack/package.json +++ b/extensions/clickclack/package.json @@ -52,20 +52,19 @@ "nativeCommandsAutoEnabled": false, "nativeSkillsAutoEnabled": false }, - "cliAddOptions": [ - { - "flags": "--base-url ", - "description": "Channel base URL" - }, - { - "flags": "--code ", - "description": "ClickClack one-time setup code or setup URL" - }, - { - "flags": "--workspace ", - "description": "ClickClack workspace id, slug, or name" - } - ] + "setup": { + "fields": [ + { "key": "code", "kind": "string", "sensitive": true, "cli": { "flags": "--code ", "description": "ClickClack one-time setup code or setup URL" } }, + { "key": "token", "kind": "string", "sensitive": true, "cli": { "flags": "--token ", "description": "ClickClack bot token" } }, + { "key": "tokenFile", "kind": "string", "sensitive": true, "cli": { "flags": "--token-file ", "description": "ClickClack bot token file" } }, + { "key": "baseUrl", "kind": "string", "cli": { "flags": "--base-url ", "description": "ClickClack API base URL" } }, + { "key": "workspace", "kind": "string", "cli": { "flags": "--workspace ", "description": "ClickClack workspace id, slug, or name" } }, + { "key": "defaultTo", "kind": "string", "cli": { "flags": "--default-to ", "description": "Default ClickClack target" } }, + { "key": "allowFrom", "kind": "string-list", "cli": { "flags": "--allow-from ", "description": "Allowed ClickClack senders" } }, + { "key": "agentActivity", "kind": "boolean", "cli": { "flags": "--agent-activity", "description": "Enable ClickClack agent activity" } }, + { "key": "useEnv", "kind": "boolean", "cli": { "flags": "--use-env", "description": "Use CLICKCLACK_BOT_TOKEN" } } + ] + } }, "install": { "clawhubSpec": "clawhub:@openclaw/clickclack", diff --git a/extensions/clickclack/src/channel.setup.ts b/extensions/clickclack/src/channel.setup.ts index c8f0c70ea35d..c86ae4a43893 100644 --- a/extensions/clickclack/src/channel.setup.ts +++ b/extensions/clickclack/src/channel.setup.ts @@ -2,7 +2,7 @@ import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; import { clickClackConfigAdapter, clickClackMeta } from "./channel-config.js"; import { clickClackConfigSchema } from "./config-schema.js"; -import { clickClackSetupAdapter } from "./setup-core.js"; +import { clickClackSetupAdapter, clickClackSetupContract } from "./setup-core.js"; import { clickClackSetupWizard } from "./setup-surface.js"; import type { ResolvedClickClackAccount } from "./types.js"; @@ -18,5 +18,6 @@ export const clickClackSetupPlugin: ChannelPlugin = { configSchema: clickClackConfigSchema, config: clickClackConfigAdapter, setup: clickClackSetupAdapter, + setupContract: clickClackSetupContract, setupWizard: clickClackSetupWizard, }; diff --git a/extensions/clickclack/src/channel.ts b/extensions/clickclack/src/channel.ts index ba9bf43a1f34..dc9f8d8b89fe 100644 --- a/extensions/clickclack/src/channel.ts +++ b/extensions/clickclack/src/channel.ts @@ -30,7 +30,7 @@ import { sendClickClackText, } from "./outbound.js"; import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; -import { clickClackSetupAdapter } from "./setup-core.js"; +import { clickClackSetupAdapter, clickClackSetupContract } from "./setup-core.js"; import { clickClackSetupWizard } from "./setup-surface.js"; import { buildClickClackTarget, @@ -128,6 +128,7 @@ export const clickClackPlugin: ChannelPlugin = create configSchema: clickClackConfigSchema, config: clickClackConfigAdapter, setup: clickClackSetupAdapter, + setupContract: clickClackSetupContract, setupWizard: clickClackSetupWizard, secrets: { secretTargetRegistryEntries, diff --git a/extensions/clickclack/src/setup-core.ts b/extensions/clickclack/src/setup-core.ts index 3fc34a63a813..8d17c13d0866 100644 --- a/extensions/clickclack/src/setup-core.ts +++ b/extensions/clickclack/src/setup-core.ts @@ -1,6 +1,10 @@ // ClickClack plugin module implements non-interactive setup behavior. import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import type { ChannelSetupAdapter, ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup"; +import { + defineChannelSetupContract, + type ChannelSetupAdapter, + type ChannelSetupInput, +} from "openclaw/plugin-sdk/channel-setup"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { @@ -391,3 +395,51 @@ export const clickClackSetupAdapter: ChannelSetupAdapter = { }); }, }; + +export const clickClackSetupContract = defineChannelSetupContract({ + fields: { + code: { + kind: "string", + sensitive: true, + cli: { flags: "--code ", description: "ClickClack one-time setup code or setup URL" }, + }, + token: { + kind: "string", + sensitive: true, + cli: { flags: "--token ", description: "ClickClack bot token" }, + }, + tokenFile: { + kind: "string", + sensitive: true, + cli: { flags: "--token-file ", description: "ClickClack bot token file" }, + }, + baseUrl: { + kind: "string", + cli: { flags: "--base-url ", description: "ClickClack API base URL" }, + }, + workspace: { + kind: "string", + cli: { + flags: "--workspace ", + description: "ClickClack workspace id, slug, or name", + }, + }, + defaultTo: { + kind: "string", + cli: { flags: "--default-to ", description: "Default ClickClack target" }, + }, + allowFrom: { + kind: "string-list", + cli: { flags: "--allow-from ", description: "Allowed ClickClack senders" }, + }, + agentActivity: { + kind: "boolean", + cli: { flags: "--agent-activity", description: "Enable ClickClack agent activity" }, + }, + useEnv: { + kind: "boolean", + cli: { flags: "--use-env", description: "Use CLICKCLACK_BOT_TOKEN" }, + }, + }, + legacyAdapter: clickClackSetupAdapter, +}); diff --git a/extensions/discord/package.json b/extensions/discord/package.json index a079cc8ece0c..cf2693289b88 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -56,6 +56,21 @@ "systemImage": "bubble.left.and.bubble.right", "markdownCapable": true, "preferSessionLookupForAnnounceTarget": true, + "setup": { + "fields": [ + { + "key": "token", + "kind": "string", + "sensitive": true, + "cli": { "flags": "--token ", "description": "Discord bot token" } + }, + { + "key": "useEnv", + "kind": "boolean", + "cli": { "flags": "--use-env", "description": "Use DISCORD_BOT_TOKEN" } + } + ] + }, "commands": { "nativeCommandsAutoEnabled": true, "nativeSkillsAutoEnabled": true diff --git a/extensions/discord/src/channel.setup.ts b/extensions/discord/src/channel.setup.ts index dfb20b4ff40c..6a90be612f39 100644 --- a/extensions/discord/src/channel.setup.ts +++ b/extensions/discord/src/channel.setup.ts @@ -2,12 +2,13 @@ import type { ResolvedDiscordAccount } from "./accounts.js"; import type { ChannelPlugin } from "./channel-api.js"; import { discordSetupWizard } from "./channel.runtime.js"; -import { discordSetupAdapter } from "./setup-adapter.js"; +import { discordSetupAdapter, discordSetupContract } from "./setup-adapter.js"; import { createDiscordPluginBase } from "./shared.js"; export const discordSetupPlugin: ChannelPlugin = { ...createDiscordPluginBase({ setupWizard: discordSetupWizard, setup: discordSetupAdapter, + setupContract: discordSetupContract, }), }; diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index f733d0058e07..c9c3a7783a92 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -81,7 +81,7 @@ import type { DiscordProbe } from "./probe.js"; import { getDiscordRuntime } from "./runtime.js"; import { discordSecurityAdapter } from "./security.js"; import { normalizeExplicitDiscordSessionKey } from "./session-key-normalization.js"; -import { discordSetupAdapter } from "./setup-adapter.js"; +import { discordSetupAdapter, discordSetupContract } from "./setup-adapter.js"; import { createDiscordPluginBase, discordConfigAdapter } from "./shared.js"; import { collectDiscordStatusIssues } from "./status-issues.js"; import { parseDiscordTarget } from "./target-parsing.js"; @@ -302,6 +302,7 @@ export const discordPlugin: ChannelPlugin base: { ...createDiscordPluginBase({ setup: discordSetupAdapter, + setupContract: discordSetupContract, }), allowlist: { ...buildLegacyDmAccountAllowlistAdapter({ diff --git a/extensions/discord/src/setup-adapter.ts b/extensions/discord/src/setup-adapter.ts index 791c5ea4e1f4..023f70effbb9 100644 --- a/extensions/discord/src/setup-adapter.ts +++ b/extensions/discord/src/setup-adapter.ts @@ -1,3 +1,4 @@ +import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; // Discord plugin module implements setup adapter behavior. import { createEnvPatchedAccountSetupAdapter, @@ -13,3 +14,18 @@ export const discordSetupAdapter: ChannelSetupAdapter = createEnvPatchedAccountS hasCredentials: (input) => Boolean(input.token), buildPatch: (input) => (input.token ? { token: input.token } : {}), }); + +export const discordSetupContract = defineChannelSetupContract({ + fields: { + token: { + kind: "string", + sensitive: true, + cli: { flags: "--token ", description: "Discord bot token" }, + }, + useEnv: { + kind: "boolean", + cli: { flags: "--use-env", description: "Use DISCORD_BOT_TOKEN" }, + }, + }, + legacyAdapter: discordSetupAdapter, +}); diff --git a/extensions/discord/src/shared.ts b/extensions/discord/src/shared.ts index 8269cbfdc5a2..78b4428eda77 100644 --- a/extensions/discord/src/shared.ts +++ b/extensions/discord/src/shared.ts @@ -95,6 +95,7 @@ export const discordConfigAdapter = createScopedChannelConfigAdapter< export function createDiscordPluginBase(params: { setup: NonNullable["setup"]>; + setupContract?: NonNullable["setupContract"]>; setupWizard?: ChannelPlugin["setupWizard"]; }): Pick< ChannelPlugin, @@ -109,12 +110,14 @@ export function createDiscordPluginBase(params: { | "configSchema" | "config" | "setup" + | "setupContract" | "messaging" | "security" | "secrets" > { return { id: DISCORD_CHANNEL, + ...(params.setupContract ? { setupContract: params.setupContract } : {}), ...(params.setupWizard ? { setupWizard: params.setupWizard } : {}), meta: { ...getChatChannelMeta(DISCORD_CHANNEL) }, capabilities: { diff --git a/extensions/feishu/package.json b/extensions/feishu/package.json index ccdb241fc9aa..32ed289a8b77 100644 --- a/extensions/feishu/package.json +++ b/extensions/feishu/package.json @@ -53,7 +53,8 @@ "lark" ], "order": 35, - "quickstartAllowFrom": true + "quickstartAllowFrom": true, + "setup": { "fields": [] } }, "install": { "npmSpec": "@openclaw/feishu", diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index fa924e204ff3..f3cd83659826 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -107,7 +107,7 @@ import { collectFeishuSecurityAuditFindings } from "./security-audit.js"; import { createFeishuSendReceipt } from "./send-result.js"; import { resolveFeishuSessionConversation } from "./session-conversation.js"; import { resolveFeishuOutboundSessionRoute } from "./session-route.js"; -import { feishuSetupAdapter } from "./setup-core.js"; +import { feishuSetupAdapter, feishuSetupContract } from "./setup-core.js"; import { feishuSetupWizard, runFeishuLogin } from "./setup-surface.js"; import { looksLikeFeishuId, normalizeFeishuTarget } from "./targets.js"; import type { FeishuConfig, FeishuProbeResult, ResolvedFeishuAccount } from "./types.js"; @@ -1689,6 +1689,7 @@ export const feishuPlugin: ChannelPlugin", - "description": "Google Chat webhook path" - }, - { - "flags": "--webhook-url ", - "description": "Google Chat webhook URL" - }, - { - "flags": "--audience-type ", - "description": "Google Chat audience type (app-url|project-number)" - }, - { - "flags": "--audience ", - "description": "Google Chat audience value (app URL or project number)" - } - ] + "setup": { + "fields": [ + { + "key": "token", + "kind": "string", + "sensitive": true, + "cli": { "flags": "--token ", "description": "Google Chat service account JSON" } + }, + { + "key": "tokenFile", + "kind": "string", + "sensitive": true, + "cli": { "flags": "--token-file ", "description": "Google Chat service account file" } + }, + { + "key": "audienceType", + "kind": "choice", + "choices": ["app-url", "project-number"], + "cli": { "flags": "--audience-type ", "description": "Google Chat audience type" } + }, + { + "key": "audience", + "kind": "string", + "cli": { "flags": "--audience ", "description": "Google Chat audience value" } + }, + { + "key": "webhookPath", + "kind": "string", + "cli": { "flags": "--webhook-path ", "description": "Google Chat webhook path" } + }, + { + "key": "webhookUrl", + "kind": "string", + "cli": { "flags": "--webhook-url ", "description": "Google Chat webhook URL" } + }, + { + "key": "useEnv", + "kind": "boolean", + "cli": { "flags": "--use-env", "description": "Use Google Chat environment credentials" } + } + ] + } }, "install": { "npmSpec": "@openclaw/googlechat", diff --git a/extensions/googlechat/src/channel-base.ts b/extensions/googlechat/src/channel-base.ts index 8be6fd7bed8b..8a2a77ec9169 100644 --- a/extensions/googlechat/src/channel-base.ts +++ b/extensions/googlechat/src/channel-base.ts @@ -15,7 +15,7 @@ import { resolveGoogleChatAccount, type ResolvedGoogleChatAccount, } from "./accounts.js"; -import { googlechatSetupAdapter } from "./setup-core.js"; +import { googlechatSetupAdapter, googlechatSetupContract } from "./setup-core.js"; import { googlechatSetupWizard } from "./setup-surface.js"; export const GOOGLECHAT_CHANNEL_ID = "googlechat" as const; @@ -76,6 +76,7 @@ type GoogleChatPluginBase = Pick< | "id" | "meta" | "setup" + | "setupContract" | "setupWizard" | "capabilities" | "streaming" @@ -93,6 +94,7 @@ export function createGoogleChatPluginBase( id: GOOGLECHAT_CHANNEL_ID, meta: { ...googlechatMeta }, setup: googlechatSetupAdapter, + setupContract: googlechatSetupContract, setupWizard: googlechatSetupWizard, capabilities: { chatTypes: ["direct", "group", "thread"], diff --git a/extensions/googlechat/src/setup-core.ts b/extensions/googlechat/src/setup-core.ts index 65ac5bd2ea70..376585516a87 100644 --- a/extensions/googlechat/src/setup-core.ts +++ b/extensions/googlechat/src/setup-core.ts @@ -1,3 +1,4 @@ +import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; // Googlechat plugin module implements setup core behavior. import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup"; import { @@ -48,3 +49,40 @@ export const googlechatSetupAdapter = createPatchedAccountSetupAdapter({ }; }, }); + +export const googlechatSetupContract = defineChannelSetupContract({ + fields: { + token: { + kind: "string", + sensitive: true, + cli: { flags: "--token ", description: "Google Chat service account JSON" }, + }, + tokenFile: { + kind: "string", + sensitive: true, + cli: { flags: "--token-file ", description: "Google Chat service account file" }, + }, + audienceType: { + kind: "choice", + choices: ["app-url", "project-number"], + cli: { flags: "--audience-type ", description: "Google Chat audience type" }, + }, + audience: { + kind: "string", + cli: { flags: "--audience ", description: "Google Chat audience value" }, + }, + webhookPath: { + kind: "string", + cli: { flags: "--webhook-path ", description: "Google Chat webhook path" }, + }, + webhookUrl: { + kind: "string", + cli: { flags: "--webhook-url ", description: "Google Chat webhook URL" }, + }, + useEnv: { + kind: "boolean", + cli: { flags: "--use-env", description: "Use Google Chat environment credentials" }, + }, + }, + legacyAdapter: googlechatSetupAdapter, +}); diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json index 6fe2de25112a..4bffab7c66ce 100644 --- a/extensions/imessage/package.json +++ b/extensions/imessage/package.json @@ -32,24 +32,31 @@ "imsg" ], "systemImage": "message.fill", - "cliAddOptions": [ - { - "flags": "--cli-path ", - "description": "Channel CLI path" - }, - { - "flags": "--db-path ", - "description": "iMessage database path" - }, - { - "flags": "--service ", - "description": "iMessage service (imessage|sms|auto)" - }, - { - "flags": "--region ", - "description": "iMessage region (for SMS)" - } - ] + "setup": { + "fields": [ + { + "key": "cliPath", + "kind": "string", + "cli": { "flags": "--cli-path ", "description": "iMessage CLI path" } + }, + { + "key": "dbPath", + "kind": "string", + "cli": { "flags": "--db-path ", "description": "iMessage database path" } + }, + { + "key": "service", + "kind": "choice", + "choices": ["imessage", "sms", "auto"], + "cli": { "flags": "--service ", "description": "iMessage service" } + }, + { + "key": "region", + "kind": "string", + "cli": { "flags": "--region ", "description": "SMS region" } + } + ] + } }, "compat": { "pluginApi": ">=2026.7.2" diff --git a/extensions/imessage/src/channel.setup.ts b/extensions/imessage/src/channel.setup.ts index 46767bc90cdb..01197f3c5663 100644 --- a/extensions/imessage/src/channel.setup.ts +++ b/extensions/imessage/src/channel.setup.ts @@ -1,12 +1,13 @@ // Imessage plugin module implements channel.setup behavior. import type { ResolvedIMessageAccount } from "./accounts.js"; import type { ChannelPlugin } from "./channel-api.js"; -import { imessageSetupAdapter } from "./setup-core.js"; +import { imessageSetupAdapter, imessageSetupContract } from "./setup-core.js"; import { createIMessagePluginBase, imessageSetupWizard } from "./shared.js"; export const imessageSetupPlugin: ChannelPlugin = { ...createIMessagePluginBase({ setupWizard: imessageSetupWizard, setup: imessageSetupAdapter, + setupContract: imessageSetupContract, }), }; diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts index 2597f91419af..42155a32dc7c 100644 --- a/extensions/imessage/src/channel.ts +++ b/extensions/imessage/src/channel.ts @@ -44,7 +44,7 @@ import { } from "./group-policy.js"; import { sanitizeOutboundText } from "./monitor/sanitize-outbound.js"; import type { IMessageProbe } from "./probe.js"; -import { imessageSetupAdapter } from "./setup-core.js"; +import { imessageSetupAdapter, imessageSetupContract } from "./setup-core.js"; import { createIMessagePluginBase, imessageSecurityAdapter, @@ -282,6 +282,7 @@ export const imessagePlugin: ChannelPlugin", description: "iMessage CLI path" }, + }, + dbPath: { + kind: "string", + cli: { flags: "--db-path ", description: "iMessage database path" }, + }, + service: { + kind: "choice", + choices: ["imessage", "sms", "auto"], + cli: { flags: "--service ", description: "iMessage service" }, + }, + region: { + kind: "string", + cli: { flags: "--region ", description: "SMS region" }, + }, + }, + legacyAdapter: imessageSetupAdapter, +}); + export const imessageSetupStatusBase = { configuredLabel: t("wizard.channels.statusConfigured"), unconfiguredLabel: t("wizard.channels.statusNeedsSetup"), diff --git a/extensions/imessage/src/shared.ts b/extensions/imessage/src/shared.ts index 773674a88a52..9e86635a491d 100644 --- a/extensions/imessage/src/shared.ts +++ b/extensions/imessage/src/shared.ts @@ -59,6 +59,7 @@ export const imessageSecurityAdapter = export function createIMessagePluginBase(params: { setupWizard?: NonNullable["setupWizard"]>; setup: NonNullable["setup"]>; + setupContract?: NonNullable["setupContract"]>; }): Pick< ChannelPlugin, | "id" @@ -70,6 +71,7 @@ export function createIMessagePluginBase(params: { | "config" | "security" | "setup" + | "setupContract" | "messaging" > { const base = createChannelPluginBase({ @@ -110,6 +112,7 @@ export function createIMessagePluginBase(params: { }, security: imessageSecurityAdapter, setup: params.setup, + ...(params.setupContract ? { setupContract: params.setupContract } : {}), }); return { ...base, diff --git a/extensions/irc/package.json b/extensions/irc/package.json index c3197f5ccff4..73825858f6f8 100644 --- a/extensions/irc/package.json +++ b/extensions/irc/package.json @@ -41,12 +41,17 @@ "internet-relay-chat" ], "systemImage": "network", - "cliAddOptions": [ - { - "flags": "--password ", - "description": "Channel password or login secret" - } - ] + "setup": { "fields": [ + { "key": "host", "kind": "string", "cli": { "flags": "--host ", "description": "IRC server host" } }, + { "key": "port", "kind": "string", "cli": { "flags": "--port ", "description": "IRC server port" } }, + { "key": "tls", "kind": "boolean", "cli": { "flags": "--tls", "description": "Use TLS for IRC" } }, + { "key": "nick", "kind": "string", "cli": { "flags": "--nick ", "description": "IRC nickname" } }, + { "key": "username", "kind": "string", "cli": { "flags": "--username ", "description": "IRC username" } }, + { "key": "realname", "kind": "string", "cli": { "flags": "--realname ", "description": "IRC real name" } }, + { "key": "channels", "kind": "string-list", "cli": { "flags": "--channels ", "description": "IRC channels" } }, + { "key": "password", "kind": "string", "sensitive": true, "cli": { "flags": "--password ", "description": "IRC server password" } }, + { "key": "useEnv", "kind": "boolean", "cli": { "flags": "--use-env", "description": "Use IRC environment configuration" } } + ] } }, "compat": { "pluginApi": ">=2026.7.2" diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts index 3c45e4b85e70..17814a42d4ab 100644 --- a/extensions/irc/src/channel.ts +++ b/extensions/irc/src/channel.ts @@ -47,7 +47,7 @@ import { ircOutboundBaseAdapter } from "./outbound-base.js"; import { resolveIrcGroupRequireMention, resolveIrcGroupToolPolicy } from "./policy.js"; import { probeIrc } from "./probe.js"; import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; -import { ircSetupAdapter } from "./setup-core.js"; +import { ircSetupAdapter, ircSetupContract } from "./setup-core.js"; import { ircSetupWizard } from "./setup-surface.js"; import type { CoreConfig, IrcProbe } from "./types.js"; @@ -171,6 +171,7 @@ export const ircPlugin: ChannelPlugin = createChat quickstartAllowFrom: true, }, setup: ircSetupAdapter, + setupContract: ircSetupContract, setupWizard: ircSetupWizard, capabilities: { chatTypes: ["direct", "group"], diff --git a/extensions/irc/src/setup-core.ts b/extensions/irc/src/setup-core.ts index a481309d7c5c..d2d3794ab081 100644 --- a/extensions/irc/src/setup-core.ts +++ b/extensions/irc/src/setup-core.ts @@ -1,5 +1,9 @@ // Irc plugin module implements setup core behavior. -import type { ChannelSetupAdapter, ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup"; +import { + defineChannelSetupContract, + type ChannelSetupAdapter, + type ChannelSetupInput, +} from "openclaw/plugin-sdk/channel-setup"; import type { DmPolicy } from "openclaw/plugin-sdk/config-contracts"; import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; import { normalizeAccountId } from "openclaw/plugin-sdk/routing"; @@ -151,3 +155,28 @@ export const ircSetupAdapter: ChannelSetupAdapter = { }) as CoreConfig; }, }; + +export const ircSetupContract = defineChannelSetupContract({ + fields: { + host: { kind: "string", cli: { flags: "--host ", description: "IRC server host" } }, + port: { kind: "string", cli: { flags: "--port ", description: "IRC server port" } }, + tls: { kind: "boolean", cli: { flags: "--tls", description: "Use TLS for IRC" } }, + nick: { kind: "string", cli: { flags: "--nick ", description: "IRC nickname" } }, + username: { kind: "string", cli: { flags: "--username ", description: "IRC username" } }, + realname: { kind: "string", cli: { flags: "--realname ", description: "IRC real name" } }, + channels: { + kind: "string-list", + cli: { flags: "--channels ", description: "IRC channels" }, + }, + password: { + kind: "string", + sensitive: true, + cli: { flags: "--password ", description: "IRC server password" }, + }, + useEnv: { + kind: "boolean", + cli: { flags: "--use-env", description: "Use IRC environment configuration" }, + }, + }, + legacyAdapter: ircSetupAdapter, +}); diff --git a/extensions/line/package.json b/extensions/line/package.json index e4a5f39860aa..02ff812a1b0c 100644 --- a/extensions/line/package.json +++ b/extensions/line/package.json @@ -47,12 +47,14 @@ "systemImage": "message", "order": 75, "quickstartAllowFrom": true, - "cliAddOptions": [ - { - "flags": "--secret-file ", - "description": "Read channel shared secret from file" - } - ] + "setup": { "fields": [ + { "key": "channelAccessToken", "kind": "string", "sensitive": true, "cli": { "flags": "--channel-access-token ", "description": "LINE channel access token" } }, + { "key": "token", "kind": "string", "sensitive": true, "cli": { "flags": "--token ", "description": "LINE channel access token (alias)" } }, + { "key": "channelSecret", "kind": "string", "sensitive": true, "cli": { "flags": "--channel-secret ", "description": "LINE channel secret" } }, + { "key": "tokenFile", "kind": "string", "sensitive": true, "cli": { "flags": "--token-file ", "description": "LINE access token file" } }, + { "key": "secretFile", "kind": "string", "sensitive": true, "cli": { "flags": "--secret-file ", "description": "LINE channel secret file" } }, + { "key": "useEnv", "kind": "boolean", "cli": { "flags": "--use-env", "description": "Use LINE environment credentials" } } + ] } }, "install": { "npmSpec": "@openclaw/line", diff --git a/extensions/line/src/channel.setup.ts b/extensions/line/src/channel.setup.ts index 6c6e971ee820..3e1eb53ce696 100644 --- a/extensions/line/src/channel.setup.ts +++ b/extensions/line/src/channel.setup.ts @@ -1,7 +1,7 @@ // Line plugin module implements channel.setup behavior. import type { ChannelPlugin, ResolvedLineAccount } from "./channel-api.js"; import { lineChannelPluginCommon } from "./channel-shared.js"; -import { lineSetupAdapter } from "./setup-core.js"; +import { lineSetupAdapter, lineSetupContract } from "./setup-core.js"; import { lineSetupWizard } from "./setup-surface.js"; export const lineSetupPlugin: ChannelPlugin = { @@ -9,4 +9,5 @@ export const lineSetupPlugin: ChannelPlugin = { ...lineChannelPluginCommon, setupWizard: lineSetupWizard, setup: lineSetupAdapter, + setupContract: lineSetupContract, }; diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index e45aa762b9a2..a6d26aa82304 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -22,7 +22,7 @@ import { inferLineTargetChatType, normalizeLineMessagingTarget } from "./messagi import { lineMessageAdapter, lineOutboundAdapter } from "./outbound.js"; import { hasLineDirectives, parseLineDirectives } from "./reply-payload-transform.js"; import { getLineRuntime } from "./runtime.js"; -import { lineSetupAdapter } from "./setup-core.js"; +import { lineSetupAdapter, lineSetupContract } from "./setup-core.js"; import { lineSetupWizard } from "./setup-surface.js"; import { lineStatusAdapter } from "./status.js"; @@ -115,6 +115,7 @@ export const linePlugin: ChannelPlugin = createChatChannelP }, directory: createEmptyChannelDirectoryAdapter(), setup: lineSetupAdapter, + setupContract: lineSetupContract, status: lineStatusAdapter, gateway: lineGatewayAdapter, message: lineMessageAdapter, diff --git a/extensions/line/src/setup-core.test.ts b/extensions/line/src/setup-core.test.ts new file mode 100644 index 000000000000..50e196a91357 --- /dev/null +++ b/extensions/line/src/setup-core.test.ts @@ -0,0 +1,30 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/setup"; +// Guards the shipped `--token` alias: released CLIs configured LINE through the +// shared token envelope switch, which must keep writing channelAccessToken. +import { describe, expect, it } from "vitest"; +import { lineSetupAdapter } from "./setup-core.js"; + +type LineChannelConfig = { channelAccessToken?: string }; + +function appliedLineConfig(input: Record): LineChannelConfig { + const cfg = lineSetupAdapter.applyAccountConfig({ + cfg: {} as OpenClawConfig, + accountId: "default", + input, + }); + return (cfg.channels?.line ?? {}) as LineChannelConfig; +} + +describe("line setup token alias", () => { + it("maps the shipped --token switch onto channelAccessToken", () => { + expect(appliedLineConfig({ token: "alias-token" }).channelAccessToken).toBe("alias-token"); + }); + + it("prefers the explicit --channel-access-token over the alias", () => { + const applied = appliedLineConfig({ + token: "alias-token", + channelAccessToken: "explicit-token", + }); + expect(applied.channelAccessToken).toBe("explicit-token"); + }); +}); diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts index 6d2c3c67872c..038ec34a8474 100644 --- a/extensions/line/src/setup-core.ts +++ b/extensions/line/src/setup-core.ts @@ -1,3 +1,4 @@ +import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; // Line plugin module implements setup core behavior. import type { ChannelSetupAdapter, @@ -95,7 +96,7 @@ export const lineSetupAdapter: ChannelSetupAdapter = { "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account.", whenNotUseEnv: [ { - someOf: ["channelAccessToken", "tokenFile"], + someOf: ["channelAccessToken", "token", "tokenFile"], message: "LINE requires channelAccessToken or --token-file (or --use-env).", }, { @@ -106,6 +107,8 @@ export const lineSetupAdapter: ChannelSetupAdapter = { }), applyAccountConfig: ({ cfg, accountId, input }) => { const typedInput = input as LineSetupInput; + // Shipped alias: `--token` writes channelAccessToken; the explicit switch wins. + const accessToken = typedInput.channelAccessToken ?? typedInput.token; const normalizedAccountId = normalizeAccountId(accountId); if (normalizedAccountId === DEFAULT_ACCOUNT_ID) { return patchLineAccountConfig({ @@ -120,8 +123,8 @@ export const lineSetupAdapter: ChannelSetupAdapter = { : { ...(typedInput.tokenFile ? { tokenFile: typedInput.tokenFile } - : typedInput.channelAccessToken - ? { channelAccessToken: typedInput.channelAccessToken } + : accessToken + ? { channelAccessToken: accessToken } : {}), ...(typedInput.secretFile ? { secretFile: typedInput.secretFile } @@ -138,8 +141,8 @@ export const lineSetupAdapter: ChannelSetupAdapter = { patch: { ...(typedInput.tokenFile ? { tokenFile: typedInput.tokenFile } - : typedInput.channelAccessToken - ? { channelAccessToken: typedInput.channelAccessToken } + : accessToken + ? { channelAccessToken: accessToken } : {}), ...(typedInput.secretFile ? { secretFile: typedInput.secretFile } @@ -151,4 +154,41 @@ export const lineSetupAdapter: ChannelSetupAdapter = { }, }; +export const lineSetupContract = defineChannelSetupContract({ + fields: { + channelAccessToken: { + kind: "string", + sensitive: true, + cli: { flags: "--channel-access-token ", description: "LINE channel access token" }, + }, + // Shipped alias: released CLIs configured LINE via the shared `--token` + // envelope switch; the adapter maps it onto channelAccessToken. + token: { + kind: "string", + sensitive: true, + cli: { flags: "--token ", description: "LINE channel access token (alias)" }, + }, + channelSecret: { + kind: "string", + sensitive: true, + cli: { flags: "--channel-secret ", description: "LINE channel secret" }, + }, + tokenFile: { + kind: "string", + sensitive: true, + cli: { flags: "--token-file ", description: "LINE access token file" }, + }, + secretFile: { + kind: "string", + sensitive: true, + cli: { flags: "--secret-file ", description: "LINE channel secret file" }, + }, + useEnv: { + kind: "boolean", + cli: { flags: "--use-env", description: "Use LINE environment credentials" }, + }, + }, + legacyAdapter: lineSetupAdapter, +}); + export { listLineAccountIds }; diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index ab047ba23c44..8f42304f521d 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -70,33 +70,20 @@ "groupAllowFromFallbackToAllowFrom": false, "warnOnEmptyGroupSenderAllowlist": true }, - "cliAddOptions": [ - { - "flags": "--homeserver ", - "description": "Matrix homeserver URL" - }, - { - "flags": "--user-id ", - "description": "Matrix user ID" - }, - { - "flags": "--access-token ", - "description": "Matrix access token" - }, - { - "flags": "--password ", - "description": "Channel password or login secret" - }, - { - "flags": "--device-name ", - "description": "Matrix device name" - }, - { - "flags": "--initial-sync-limit ", - "description": "Matrix initial sync limit", - "valueType": "int" - } - ], + "setup": { + "fields": [ + { "key": "homeserver", "kind": "string", "cli": { "flags": "--homeserver ", "description": "Matrix homeserver URL" } }, + { "key": "userId", "kind": "string", "cli": { "flags": "--user-id ", "description": "Matrix user id" } }, + { "key": "accessToken", "kind": "string", "sensitive": true, "cli": { "flags": "--access-token ", "description": "Matrix access token" } }, + { "key": "password", "kind": "string", "sensitive": true, "cli": { "flags": "--password ", "description": "Matrix password" } }, + { "key": "deviceName", "kind": "string", "cli": { "flags": "--device-name ", "description": "Matrix device name" } }, + { "key": "avatarUrl", "kind": "string", "cli": { "flags": "--avatar-url ", "description": "Matrix avatar URL" } }, + { "key": "initialSyncLimit", "kind": "integer", "cli": { "flags": "--initial-sync-limit ", "description": "Matrix initial sync room limit" } }, + { "key": "proxy", "kind": "string", "cli": { "flags": "--proxy ", "description": "Matrix proxy URL" } }, + { "key": "dangerouslyAllowPrivateNetwork", "kind": "boolean", "cli": { "flags": "--dangerously-allow-private-network", "description": "Allow private-network Matrix homeservers" } }, + { "key": "useEnv", "kind": "boolean", "cli": { "flags": "--use-env", "description": "Use Matrix environment credentials" } } + ] + }, "persistedAuthState": { "specifier": "./auth-presence", "exportName": "hasAnyMatrixAuth" diff --git a/extensions/matrix/src/channel.setup.ts b/extensions/matrix/src/channel.setup.ts index b3ecdba6f1eb..fec96a4d3a69 100644 --- a/extensions/matrix/src/channel.setup.ts +++ b/extensions/matrix/src/channel.setup.ts @@ -4,7 +4,11 @@ import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; import { matrixConfigAdapter } from "./config-adapter.js"; import { MatrixChannelConfigSchema } from "./config-schema.js"; import { resolveMatrixAccount, type ResolvedMatrixAccount } from "./matrix/accounts.js"; -import { createMatrixSetupWizardProxy, matrixSetupAdapter } from "./setup-core.js"; +import { + createMatrixSetupWizardProxy, + matrixSetupAdapter, + matrixSetupContract, +} from "./setup-core.js"; const matrixSetupWizard = createMatrixSetupWizardProxy(async () => ({ matrixSetupWizard: (await import("./setup-surface.js")).matrixSetupWizard, @@ -24,6 +28,7 @@ export const matrixSetupPlugin: ChannelPlugin = { }, setupWizard: matrixSetupWizard, setup: matrixSetupAdapter, + setupContract: matrixSetupContract, capabilities: { chatTypes: ["direct", "group", "thread"], polls: true, diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index 2bed709e8b00..e4ec8fd94931 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -77,7 +77,11 @@ import { resolveSingleAccountPromotionTarget, singleAccountKeysToMove, } from "./setup-contract.js"; -import { createMatrixSetupWizardProxy, matrixSetupAdapter } from "./setup-core.js"; +import { + createMatrixSetupWizardProxy, + matrixSetupAdapter, + matrixSetupContract, +} from "./setup-core.js"; import { defaultTopLevelPlacement, resolveMatrixInboundConversation, @@ -525,6 +529,12 @@ export const matrixPlugin: ChannelPlugin = namedAccountPromotionKeys, resolveSingleAccountPromotionTarget, }, + setupContract: { + ...matrixSetupContract, + singleAccountKeysToMove, + namedAccountPromotionKeys, + resolveSingleAccountPromotionTarget, + }, bindings: { compileConfiguredBinding: ({ conversationId }) => normalizeMatrixAcpConversationId(conversationId), diff --git a/extensions/matrix/src/setup-core.ts b/extensions/matrix/src/setup-core.ts index 01f623b909b5..d4222abb60f5 100644 --- a/extensions/matrix/src/setup-core.ts +++ b/extensions/matrix/src/setup-core.ts @@ -1,3 +1,4 @@ +import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; // Matrix plugin module implements setup core behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { @@ -153,3 +154,48 @@ export const matrixSetupAdapter: ChannelSetupAdapter = { }); }, }; + +export const matrixSetupContract = defineChannelSetupContract({ + fields: { + homeserver: { + kind: "string", + cli: { flags: "--homeserver ", description: "Matrix homeserver URL" }, + }, + userId: { kind: "string", cli: { flags: "--user-id ", description: "Matrix user id" } }, + accessToken: { + kind: "string", + sensitive: true, + cli: { flags: "--access-token ", description: "Matrix access token" }, + }, + password: { + kind: "string", + sensitive: true, + cli: { flags: "--password ", description: "Matrix password" }, + }, + deviceName: { + kind: "string", + cli: { flags: "--device-name ", description: "Matrix device name" }, + }, + avatarUrl: { + kind: "string", + cli: { flags: "--avatar-url ", description: "Matrix avatar URL" }, + }, + initialSyncLimit: { + kind: "integer", + cli: { flags: "--initial-sync-limit ", description: "Matrix initial sync room limit" }, + }, + proxy: { kind: "string", cli: { flags: "--proxy ", description: "Matrix proxy URL" } }, + dangerouslyAllowPrivateNetwork: { + kind: "boolean", + cli: { + flags: "--dangerously-allow-private-network", + description: "Allow private-network Matrix homeservers", + }, + }, + useEnv: { + kind: "boolean", + cli: { flags: "--use-env", description: "Use Matrix environment credentials" }, + }, + }, + legacyAdapter: matrixSetupAdapter, +}); diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json index 61e2ce8cb5fc..37157e1e242a 100644 --- a/extensions/mattermost/package.json +++ b/extensions/mattermost/package.json @@ -44,16 +44,12 @@ "docsLabel": "mattermost", "blurb": "self-hosted Slack-style chat; install the plugin to enable.", "order": 65, - "cliAddOptions": [ - { - "flags": "--bot-token ", - "description": "Bot token" - }, - { - "flags": "--http-url ", - "description": "Channel HTTP service URL" - } - ] + "setup": { "fields": [ + { "key": "token", "kind": "string", "sensitive": true, "cli": { "flags": "--token ", "description": "Mattermost bot token" } }, + { "key": "botToken", "kind": "string", "sensitive": true, "cli": { "flags": "--bot-token ", "description": "Mattermost bot token" } }, + { "key": "httpUrl", "kind": "string", "cli": { "flags": "--http-url ", "description": "Mattermost server URL" } }, + { "key": "useEnv", "kind": "boolean", "cli": { "flags": "--use-env", "description": "Use Mattermost environment credentials" } } + ] } }, "install": { "clawhubSpec": "clawhub:@openclaw/mattermost", diff --git a/extensions/mattermost/src/channel.setup.ts b/extensions/mattermost/src/channel.setup.ts index 6a6616bd8ca2..6f17063668c8 100644 --- a/extensions/mattermost/src/channel.setup.ts +++ b/extensions/mattermost/src/channel.setup.ts @@ -9,7 +9,7 @@ import { } from "./channel-config-shared.js"; import { MattermostChannelConfigSchema } from "./config-surface.js"; import type { ResolvedMattermostAccount } from "./mattermost/accounts.js"; -import { mattermostSetupAdapter } from "./setup-core.js"; +import { mattermostSetupAdapter, mattermostSetupContract } from "./setup-core.js"; import { mattermostSetupWizard } from "./setup-surface.js"; export const mattermostSetupPlugin: ChannelPlugin = { @@ -42,5 +42,6 @@ export const mattermostSetupPlugin: ChannelPlugin = { resolveGatewayAuthBypassPaths: resolveMattermostGatewayAuthBypassPaths, }, setup: mattermostSetupAdapter, + setupContract: mattermostSetupContract, setupWizard: mattermostSetupWizard, }; diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index 99568d9a6cce..f8b734121bd5 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -63,7 +63,7 @@ import { import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js"; import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; import { resolveMattermostOutboundSessionRoute } from "./session-route.js"; -import { mattermostSetupAdapter } from "./setup-core.js"; +import { mattermostSetupAdapter, mattermostSetupContract } from "./setup-core.js"; import { mattermostSetupWizard } from "./setup-surface.js"; import type { MattermostConfig } from "./types.js"; @@ -787,6 +787,7 @@ export const mattermostPlugin: ChannelPlugin = create ...meta, }, setup: mattermostSetupAdapter, + setupContract: mattermostSetupContract, setupWizard: mattermostSetupWizard, capabilities: { chatTypes: ["direct", "channel", "group", "thread"], diff --git a/extensions/mattermost/src/setup-core.ts b/extensions/mattermost/src/setup-core.ts index 890194d8eb77..be4eb837328e 100644 --- a/extensions/mattermost/src/setup-core.ts +++ b/extensions/mattermost/src/setup-core.ts @@ -1,6 +1,10 @@ // Mattermost plugin module implements setup core behavior. import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import type { ChannelSetupAdapter, ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup"; +import { + defineChannelSetupContract, + type ChannelSetupAdapter, + type ChannelSetupInput, +} from "openclaw/plugin-sdk/channel-setup"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { applyAccountNameToChannelSection, @@ -114,3 +118,27 @@ export const mattermostSetupAdapter: ChannelSetupAdapter = { }); }, }; + +export const mattermostSetupContract = defineChannelSetupContract({ + fields: { + token: { + kind: "string", + sensitive: true, + cli: { flags: "--token ", description: "Mattermost bot token" }, + }, + botToken: { + kind: "string", + sensitive: true, + cli: { flags: "--bot-token ", description: "Mattermost bot token" }, + }, + httpUrl: { + kind: "string", + cli: { flags: "--http-url ", description: "Mattermost server URL" }, + }, + useEnv: { + kind: "boolean", + cli: { flags: "--use-env", description: "Use Mattermost environment credentials" }, + }, + }, + legacyAdapter: mattermostSetupAdapter, +}); diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index 69901d6866a5..4b71524b9ca2 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -59,7 +59,8 @@ "groupModel": "hybrid", "groupAllowFromFallbackToAllowFrom": true, "warnOnEmptyGroupSenderAllowlist": true - } + }, + "setup": { "fields": [] } }, "install": { "npmSpec": "@openclaw/msteams", diff --git a/extensions/msteams/src/channel.setup.ts b/extensions/msteams/src/channel.setup.ts index 0e5153113df2..7799df1e55a2 100644 --- a/extensions/msteams/src/channel.setup.ts +++ b/extensions/msteams/src/channel.setup.ts @@ -5,7 +5,7 @@ import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel- import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { MSTeamsChannelConfigSchema } from "./config-schema.js"; -import { msteamsSetupAdapter } from "./setup-core.js"; +import { msteamsSetupAdapter, msteamsSetupContract } from "./setup-core.js"; import { msteamsSetupWizard } from "./setup-surface.js"; import { resolveMSTeamsCredentials } from "./token.js"; @@ -75,4 +75,5 @@ export const msteamsSetupPlugin: ChannelPlugin = { }, setupWizard: msteamsSetupWizard, setup: msteamsSetupAdapter, + setupContract: msteamsSetupContract, }; diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index f21a3a8eb6ef..0477933aeb8f 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -63,7 +63,7 @@ import { resolveMSTeamsUserAllowlist, } from "./resolve-allowlist.js"; import { resolveMSTeamsOutboundSessionRoute } from "./session-route.js"; -import { msteamsSetupAdapter } from "./setup-core.js"; +import { msteamsSetupAdapter, msteamsSetupContract } from "./setup-core.js"; import { msteamsSetupWizard } from "./setup-surface.js"; import { resolveMSTeamsCredentials } from "./token.js"; @@ -571,6 +571,7 @@ export const msteamsPlugin: ChannelPlugin", - "description": "Channel base URL" - }, - { - "flags": "--secret ", - "description": "Channel shared secret" - }, - { - "flags": "--secret-file ", - "description": "Read channel shared secret from file" - }, - { - "flags": "--password ", - "description": "Channel password or login secret" - }, - { - "flags": "--url ", - "description": "Channel setup URL" - } - ] + "setup": { "fields": [ + { "key": "baseUrl", "kind": "string", "cli": { "flags": "--base-url ", "description": "Nextcloud base URL" } }, + { "key": "url", "kind": "string", "cli": { "flags": "--url ", "description": "Legacy Nextcloud base URL alias" } }, + { "key": "secret", "kind": "string", "sensitive": true, "cli": { "flags": "--secret ", "description": "Nextcloud Talk bot secret" } }, + { "key": "token", "kind": "string", "sensitive": true, "cli": { "flags": "--token ", "description": "Legacy Nextcloud bot secret alias" } }, + { "key": "password", "kind": "string", "sensitive": true, "cli": { "flags": "--password ", "description": "Legacy Nextcloud bot secret alias" } }, + { "key": "secretFile", "kind": "string", "sensitive": true, "cli": { "flags": "--secret-file ", "description": "Nextcloud Talk bot secret file" } }, + { "key": "tokenFile", "kind": "string", "sensitive": true, "cli": { "flags": "--token-file ", "description": "Legacy Nextcloud bot secret file alias" } }, + { "key": "useEnv", "kind": "boolean", "cli": { "flags": "--use-env", "description": "Use Nextcloud Talk environment credentials" } } + ] } }, "install": { "npmSpec": "@openclaw/nextcloud-talk", diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 8094dc76efb3..f44ec13f74a9 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -34,7 +34,7 @@ import { import { getNextcloudTalkRuntime } from "./runtime.js"; import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; import { resolveNextcloudTalkOutboundSessionRoute } from "./session-route.js"; -import { nextcloudTalkSetupAdapter } from "./setup-core.js"; +import { nextcloudTalkSetupAdapter, nextcloudTalkSetupContract } from "./setup-core.js"; import { nextcloudTalkSetupWizard } from "./setup-surface.js"; const meta = { @@ -123,6 +123,7 @@ export const nextcloudTalkPlugin: ChannelPlugin = collectRuntimeConfigAssignments, }, setup: nextcloudTalkSetupAdapter, + setupContract: nextcloudTalkSetupContract, status: createComputedAccountStatusAdapter({ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), buildChannelSummary: ({ snapshot }) => diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index d700f6c096a4..e85a98245add 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -1,5 +1,9 @@ // Nextcloud Talk plugin module implements setup core behavior. -import type { ChannelSetupAdapter, ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup"; +import { + defineChannelSetupContract, + type ChannelSetupAdapter, + type ChannelSetupInput, +} from "openclaw/plugin-sdk/channel-setup"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { @@ -271,3 +275,46 @@ export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { return setNextcloudTalkAccountConfig(next as CoreConfig, accountId, patch); }, }; + +export const nextcloudTalkSetupContract = defineChannelSetupContract({ + fields: { + baseUrl: { + kind: "string", + cli: { flags: "--base-url ", description: "Nextcloud base URL" }, + }, + url: { + kind: "string", + cli: { flags: "--url ", description: "Legacy Nextcloud base URL alias" }, + }, + secret: { + kind: "string", + sensitive: true, + cli: { flags: "--secret ", description: "Nextcloud Talk bot secret" }, + }, + token: { + kind: "string", + sensitive: true, + cli: { flags: "--token ", description: "Legacy Nextcloud bot secret alias" }, + }, + password: { + kind: "string", + sensitive: true, + cli: { flags: "--password ", description: "Legacy Nextcloud bot secret alias" }, + }, + secretFile: { + kind: "string", + sensitive: true, + cli: { flags: "--secret-file ", description: "Nextcloud Talk bot secret file" }, + }, + tokenFile: { + kind: "string", + sensitive: true, + cli: { flags: "--token-file ", description: "Legacy Nextcloud bot secret file alias" }, + }, + useEnv: { + kind: "boolean", + cli: { flags: "--use-env", description: "Use Nextcloud Talk environment credentials" }, + }, + }, + legacyAdapter: nextcloudTalkSetupAdapter, +}); diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json index 7b6fc5f80ef1..23aead297801 100644 --- a/extensions/nostr/package.json +++ b/extensions/nostr/package.json @@ -44,16 +44,13 @@ "blurb": "Decentralized protocol; encrypted DMs via NIP-04.", "order": 55, "quickstartAllowFrom": true, - "cliAddOptions": [ - { - "flags": "--private-key ", - "description": "Nostr private key (nsec... or hex)" - }, - { - "flags": "--relay-urls ", - "description": "Nostr relay URLs (comma-separated)" - } - ] + "setup": { + "fields": [ + { "key": "privateKey", "kind": "string", "sensitive": true, "cli": { "flags": "--private-key ", "description": "Nostr private key" } }, + { "key": "relayUrls", "kind": "string", "cli": { "flags": "--relay-urls ", "description": "Nostr relay URLs" } }, + { "key": "useEnv", "kind": "boolean", "cli": { "flags": "--use-env", "description": "Use NOSTR_PRIVATE_KEY" } } + ] + } }, "install": { "npmSpec": "@openclaw/nostr", diff --git a/extensions/nostr/src/channel.setup.ts b/extensions/nostr/src/channel.setup.ts index 51ca1fc505db..2055fe145b0e 100644 --- a/extensions/nostr/src/channel.setup.ts +++ b/extensions/nostr/src/channel.setup.ts @@ -10,7 +10,7 @@ import { import { buildChannelConfigSchema, type ChannelPlugin } from "./channel-api.js"; import { NostrConfigSchema } from "./config-schema.js"; import { DEFAULT_RELAYS } from "./default-relays.js"; -import { createNostrSetupAdapter } from "./setup-adapter.js"; +import { createNostrSetupAdapter, createNostrSetupContract } from "./setup-adapter.js"; const t = createSetupTranslator(); @@ -102,6 +102,7 @@ const nostrSetupAdapter = createNostrSetupAdapter({ resolveAccountId: (cfg, accountId) => accountId?.trim() || resolveDefaultSetupNostrAccountId(cfg), validatePrivateKey: looksLikeNostrPrivateKey, }); +const nostrSetupContract = createNostrSetupContract(nostrSetupAdapter); const nostrSetupWizard = createDelegatedSetupWizardProxy({ channel, @@ -147,6 +148,7 @@ export const nostrSetupPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.nostr"] }, configSchema: buildChannelConfigSchema(NostrConfigSchema), setup: nostrSetupAdapter, + setupContract: nostrSetupContract, setupWizard: nostrSetupWizard, config: { listAccountIds: listSetupNostrAccountIds, diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index e9878a3707f4..255f85402fbf 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -33,7 +33,7 @@ import { import { normalizePubkey } from "./nostr-key-utils.js"; import type { ProfilePublishResult } from "./nostr-profile.js"; import { resolveNostrOutboundSessionRoute } from "./session-route.js"; -import { nostrSetupAdapter, nostrSetupWizard } from "./setup-surface.js"; +import { nostrSetupAdapter, nostrSetupContract, nostrSetupWizard } from "./setup-surface.js"; import { listNostrAccountIds, resolveDefaultNostrAccountId, @@ -140,6 +140,7 @@ export const nostrPlugin: ChannelPlugin = createChatChanne reload: { configPrefixes: ["channels.nostr"] }, configSchema: buildChannelConfigSchema(NostrConfigSchema), setup: nostrSetupAdapter, + setupContract: nostrSetupContract, setupWizard: nostrSetupWizard, config: { ...nostrConfigAdapter, diff --git a/extensions/nostr/src/setup-adapter.ts b/extensions/nostr/src/setup-adapter.ts index 8cb56275457b..879c3a9a00c5 100644 --- a/extensions/nostr/src/setup-adapter.ts +++ b/extensions/nostr/src/setup-adapter.ts @@ -1,5 +1,9 @@ // Nostr plugin module implements setup adapter behavior. -import type { ChannelSetupAdapter, ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup"; +import { + defineChannelSetupContract, + type ChannelSetupAdapter, + type ChannelSetupInput, +} from "openclaw/plugin-sdk/channel-setup"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; import { patchTopLevelChannelConfigSection, splitSetupEntries } from "openclaw/plugin-sdk/setup"; @@ -81,3 +85,24 @@ export function createNostrSetupAdapter(params: { }, }; } + +export function createNostrSetupContract(adapter: ChannelSetupAdapter) { + return defineChannelSetupContract({ + fields: { + privateKey: { + kind: "string", + sensitive: true, + cli: { flags: "--private-key ", description: "Nostr private key" }, + }, + relayUrls: { + kind: "string", + cli: { flags: "--relay-urls ", description: "Nostr relay URLs" }, + }, + useEnv: { + kind: "boolean", + cli: { flags: "--use-env", description: "Use NOSTR_PRIVATE_KEY" }, + }, + }, + legacyAdapter: adapter, + }); +} diff --git a/extensions/nostr/src/setup-surface.ts b/extensions/nostr/src/setup-surface.ts index 0321c69724d1..d50949eb3425 100644 --- a/extensions/nostr/src/setup-surface.ts +++ b/extensions/nostr/src/setup-surface.ts @@ -19,7 +19,12 @@ import { } from "openclaw/plugin-sdk/setup"; import { DEFAULT_RELAYS } from "./default-relays.js"; import { getPublicKeyFromPrivate, normalizePubkey } from "./nostr-key-utils.js"; -import { buildNostrSetupPatch, createNostrSetupAdapter, parseRelayUrls } from "./setup-adapter.js"; +import { + buildNostrSetupPatch, + createNostrSetupAdapter, + createNostrSetupContract, + parseRelayUrls, +} from "./setup-adapter.js"; import { resolveDefaultNostrAccountId, resolveNostrAccount } from "./types.js"; const t = createSetupTranslator(); @@ -84,6 +89,7 @@ export const nostrSetupAdapter = createNostrSetupAdapter({ } }, }); +export const nostrSetupContract = createNostrSetupContract(nostrSetupAdapter); export const nostrSetupWizard: ChannelSetupWizard = { channel, diff --git a/extensions/qa-channel/package.json b/extensions/qa-channel/package.json index 13d3b42fab95..d1466f37593d 100644 --- a/extensions/qa-channel/package.json +++ b/extensions/qa-channel/package.json @@ -46,12 +46,11 @@ "setup": false, "docs": false }, - "cliAddOptions": [ - { - "flags": "--base-url ", - "description": "Channel base URL" - } - ] + "setup": { "fields": [ + { "key": "baseUrl", "kind": "string", "cli": { "flags": "--base-url ", "description": "QA channel base URL" } }, + { "key": "botUserId", "kind": "string", "cli": { "flags": "--bot-user-id ", "description": "QA channel bot user id" } }, + { "key": "botDisplayName", "kind": "string", "cli": { "flags": "--bot-display-name ", "description": "QA channel bot display name" } } + ] } } } } diff --git a/extensions/qa-channel/src/channel-base.ts b/extensions/qa-channel/src/channel-base.ts index 4dd9ab550ed2..8c7d65b15a81 100644 --- a/extensions/qa-channel/src/channel-base.ts +++ b/extensions/qa-channel/src/channel-base.ts @@ -1,3 +1,4 @@ +import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; // Qa Channel plugin module implements channel base behavior. import { listQaChannelAccountIds, @@ -25,12 +26,20 @@ const qaChannelSetupMeta = qaChannelRuntimeMeta; type QaChannelPluginBase = Pick< ChannelPlugin, - "id" | "meta" | "capabilities" | "reload" | "configSchema" | "setup" | "config" + "id" | "meta" | "capabilities" | "reload" | "configSchema" | "setup" | "setupContract" | "config" >; export function createQaChannelPluginBase( meta: ChannelPlugin["meta"] = qaChannelSetupMeta, ): QaChannelPluginBase { + const setup = { + applyAccountConfig: ({ cfg, accountId, input }) => + applyQaSetup({ + cfg, + accountId, + input: input as QaChannelSetupInput, + }), + } satisfies NonNullable; return { id: QA_CHANNEL_ID, meta, @@ -39,14 +48,24 @@ export function createQaChannelPluginBase( }, reload: { configPrefixes: ["channels.qa-channel"] }, configSchema: qaChannelPluginConfigSchema, - setup: { - applyAccountConfig: ({ cfg, accountId, input }) => - applyQaSetup({ - cfg, - accountId, - input: input as QaChannelSetupInput, - }), - }, + setup, + setupContract: defineChannelSetupContract({ + fields: { + baseUrl: { + kind: "string", + cli: { flags: "--base-url ", description: "QA channel base URL" }, + }, + botUserId: { + kind: "string", + cli: { flags: "--bot-user-id ", description: "QA channel bot user id" }, + }, + botDisplayName: { + kind: "string", + cli: { flags: "--bot-display-name ", description: "QA channel bot display name" }, + }, + }, + legacyAdapter: setup, + }), config: { listAccountIds: (cfg) => listQaChannelAccountIds(cfg as CoreConfig), resolveAccount: (cfg, accountId) => diff --git a/extensions/qa-lab/src/crabline-transport.test.ts b/extensions/qa-lab/src/crabline-transport.test.ts index ca2266f4071c..51e8383e63c0 100644 --- a/extensions/qa-lab/src/crabline-transport.test.ts +++ b/extensions/qa-lab/src/crabline-transport.test.ts @@ -473,17 +473,22 @@ describe("crabline transport", () => { try { expect(transport.requiredPluginIds).toEqual(["signal"]); - expect(transport.createGatewayConfig({ baseUrl: "http://127.0.0.1:1" })).toMatchObject({ + const gatewayConfig = transport.createGatewayConfig({ baseUrl: "http://127.0.0.1:1" }); + expect(gatewayConfig).toMatchObject({ channels: { signal: { account: "+15550000000", - apiMode: "native", - autoStart: false, enabled: true, - httpUrl: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+$/u), + transport: { + kind: "external-native", + url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:\d+$/u), + }, }, }, }); + expect(gatewayConfig.channels?.signal).not.toHaveProperty("apiMode"); + expect(gatewayConfig.channels?.signal).not.toHaveProperty("autoStart"); + expect(gatewayConfig.channels?.signal).not.toHaveProperty("httpUrl"); expect(transport.createRuntimeEnvPatch?.()).toEqual({}); const delivery = transport.buildAgentDelivery({ target: "dm:alice" }); expect(delivery).toMatchObject({ @@ -528,8 +533,8 @@ describe("crabline transport", () => { try { const delivery = transport.buildAgentDelivery({ target: "dm:alice" }); const config = transport.createGatewayConfig({ baseUrl: "http://127.0.0.1:1" }); - const signal = config.channels?.signal as { httpUrl?: string } | undefined; - const signalBaseUrl = requireString(signal?.httpUrl, "Signal HTTP URL"); + const signal = config.channels?.signal as { transport?: { url?: string } } | undefined; + const signalBaseUrl = requireString(signal?.transport?.url, "Signal HTTP URL"); const { response, release } = await fetchWithSsrFGuard({ url: `${signalBaseUrl}/api/v1/rpc`, init: { diff --git a/extensions/qa-lab/src/crabline-transport.ts b/extensions/qa-lab/src/crabline-transport.ts index 47273539aa49..43a30d6472cf 100644 --- a/extensions/qa-lab/src/crabline-transport.ts +++ b/extensions/qa-lab/src/crabline-transport.ts @@ -55,6 +55,37 @@ type QaCrablineTransportState = QaTransportState & { rememberProviderTarget: (providerTargetKey: string, qaTarget: string) => void; }; +function normalizeCrablineSignalGatewayConfig(config: OpenClawConfig): OpenClawConfig { + const signal = config.channels?.signal as unknown; + if (!isRecord(signal)) { + return config; + } + const httpUrl = readStringValue(signal.httpUrl); + if (!httpUrl) { + return config; + } + + // Crabline still emits the retired Signal transport fields. Keep that dependency detail at + // this adapter boundary so the Gateway receives only the account-owned canonical shape. + const canonicalSignal = { ...signal }; + delete canonicalSignal.apiMode; + delete canonicalSignal.autoStart; + delete canonicalSignal.httpUrl; + return { + ...config, + channels: { + ...config.channels, + signal: { + ...canonicalSignal, + transport: { + kind: "external-native", + url: httpUrl, + }, + }, + }, + } as OpenClawConfig; +} + function formatLogicalQaTarget({ conversation, threadId }: QaBusInboundMessageInput) { const prefix = conversation.kind === "direct" ? "dm" : conversation.kind; return threadId ? `thread:${conversation.id}/${threadId}` : `${prefix}:${conversation.id}`; @@ -375,7 +406,11 @@ class QaCrablineTransport extends QaStateBackedTransportAdapter { } createGatewayConfig = (params: { baseUrl: string }): QaTransportGatewayConfig => { - const config = this.#adapter.createGatewayConfig(params) as OpenClawConfig; + const rawConfig = this.#adapter.createGatewayConfig(params) as OpenClawConfig; + const config = + this.#selection.channel === "signal" + ? normalizeCrablineSignalGatewayConfig(rawConfig) + : rawConfig; if (this.#selection.channel !== "telegram") { return config as QaTransportGatewayConfig; } diff --git a/extensions/qqbot/package.json b/extensions/qqbot/package.json index b3ce89515605..972433f6b9aa 100644 --- a/extensions/qqbot/package.json +++ b/extensions/qqbot/package.json @@ -52,7 +52,12 @@ "docsPath": "/channels/qqbot", "docsLabel": "qqbot", "blurb": "connect to QQ via official QQ Bot API with group chat and direct message support.", - "systemImage": "bubble.left.and.bubble.right" + "systemImage": "bubble.left.and.bubble.right", + "setup": { "fields": [ + { "key": "token", "kind": "string", "sensitive": true, "cli": { "flags": "--token ", "description": "QQBot app id and client secret" } }, + { "key": "tokenFile", "kind": "string", "sensitive": true, "cli": { "flags": "--token-file ", "description": "QQBot client secret file" } }, + { "key": "useEnv", "kind": "boolean", "cli": { "flags": "--use-env", "description": "Use QQBOT environment credentials" } } + ] } }, "install": { "npmSpec": "@openclaw/qqbot", diff --git a/extensions/qqbot/src/bridge/config-shared.ts b/extensions/qqbot/src/bridge/config-shared.ts index 7d852d97082d..88b47bf84a71 100644 --- a/extensions/qqbot/src/bridge/config-shared.ts +++ b/extensions/qqbot/src/bridge/config-shared.ts @@ -1,3 +1,4 @@ +import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; // Qqbot helper module supports config shared behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { @@ -132,3 +133,23 @@ export const qqbotSetupAdapterShared = { input: ChannelSetupInput; }) => applyQQBotSetupAccountConfig({ cfg, accountId, input }), }; + +export const qqbotSetupContract = defineChannelSetupContract({ + fields: { + token: { + kind: "string", + sensitive: true, + cli: { flags: "--token ", description: "QQBot app id and client secret" }, + }, + tokenFile: { + kind: "string", + sensitive: true, + cli: { flags: "--token-file ", description: "QQBot client secret file" }, + }, + useEnv: { + kind: "boolean", + cli: { flags: "--use-env", description: "Use QQBOT environment credentials" }, + }, + }, + legacyAdapter: qqbotSetupAdapterShared, +}); diff --git a/extensions/qqbot/src/channel.setup.ts b/extensions/qqbot/src/channel.setup.ts index f33d80ec18d9..89c2e61b76da 100644 --- a/extensions/qqbot/src/channel.setup.ts +++ b/extensions/qqbot/src/channel.setup.ts @@ -1,7 +1,12 @@ // Qqbot plugin module implements channel.setup behavior. import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; import "./bridge/bootstrap.js"; -import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } from "./bridge/config-shared.js"; +import { + qqbotConfigAdapter, + qqbotMeta, + qqbotSetupAdapterShared, + qqbotSetupContract, +} from "./bridge/config-shared.js"; import { qqbotSetupWizard } from "./bridge/setup/surface.js"; import { qqbotChannelConfigSchema } from "./config-schema.js"; import type { ResolvedQQBotAccount } from "./types.js"; @@ -31,4 +36,5 @@ export const qqbotSetupPlugin: ChannelPlugin = { setup: { ...qqbotSetupAdapterShared, }, + setupContract: qqbotSetupContract, }; diff --git a/extensions/qqbot/src/channel.ts b/extensions/qqbot/src/channel.ts index 1b6fa6528c32..b8ecd2bf9118 100644 --- a/extensions/qqbot/src/channel.ts +++ b/extensions/qqbot/src/channel.ts @@ -14,7 +14,12 @@ import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import "./bridge/bootstrap.js"; import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking"; import { getQQBotApprovalCapability } from "./bridge/approval/capability.js"; -import { qqbotConfigAdapter, qqbotMeta, qqbotSetupAdapterShared } from "./bridge/config-shared.js"; +import { + qqbotConfigAdapter, + qqbotMeta, + qqbotSetupAdapterShared, + qqbotSetupContract, +} from "./bridge/config-shared.js"; import { applyQQBotAccountConfig, DEFAULT_ACCOUNT_ID, @@ -285,6 +290,7 @@ export const qqbotPlugin: ChannelPlugin = { setup: { ...qqbotSetupAdapterShared, }, + setupContract: qqbotSetupContract, approvalCapability: getQQBotApprovalCapability(), groups: { resolveToolPolicy: resolveQQBotGroupToolPolicy, diff --git a/extensions/raft/package.json b/extensions/raft/package.json index 10d81ffd0936..ea29a32fae40 100644 --- a/extensions/raft/package.json +++ b/extensions/raft/package.json @@ -52,7 +52,16 @@ "docsPath": "/channels/raft", "docsLabel": "raft", "blurb": "Raft CLI wake bridge for human and agent collaboration.", - "order": 72 + "order": 72, + "setup": { + "fields": [ + { + "key": "profile", + "kind": "string", + "cli": { "flags": "--profile ", "description": "Raft CLI profile" } + } + ] + } }, "release": { "publishToClawHub": true, diff --git a/extensions/raft/src/channel.ts b/extensions/raft/src/channel.ts index 7504484a11b9..449c9a639b04 100644 --- a/extensions/raft/src/channel.ts +++ b/extensions/raft/src/channel.ts @@ -39,6 +39,7 @@ export const raftPlugin: ChannelPlugin = createC chatTypes: ["direct"], }, setup: raftSetupPlugin.setup, + setupContract: raftSetupPlugin.setupContract, setupWizard: raftSetupPlugin.setupWizard, reload: { configPrefixes: ["channels.raft"] }, configSchema: raftChannelConfigSchema, diff --git a/extensions/raft/src/setup.ts b/extensions/raft/src/setup.ts index 69531155f8ed..3ee2c795fc19 100644 --- a/extensions/raft/src/setup.ts +++ b/extensions/raft/src/setup.ts @@ -1,3 +1,4 @@ +import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; // Raft plugin setup owns only the Raft CLI profile, never Raft credentials. import { @@ -41,6 +42,16 @@ const raftSetupAdapter = createPatchedAccountSetupAdapter({ }, }); +const raftSetupContract = defineChannelSetupContract({ + fields: { + profile: { + kind: "string", + cli: { flags: "--profile ", description: "Raft CLI profile" }, + }, + }, + legacyAdapter: raftSetupAdapter, +}); + export const raftSetupPlugin: ChannelPlugin = { id: RAFT_CHANNEL_ID, meta: { @@ -56,6 +67,7 @@ export const raftSetupPlugin: ChannelPlugin = { chatTypes: ["direct"], }, setup: raftSetupAdapter, + setupContract: raftSetupContract, config: { listAccountIds: listRaftAccountIds, resolveAccount: (cfg, accountId) => resolveRaftAccount({ cfg, accountId }), diff --git a/extensions/reef/package.json b/extensions/reef/package.json index 14a80900c4fb..43eaa3c04e94 100644 --- a/extensions/reef/package.json +++ b/extensions/reef/package.json @@ -26,7 +26,8 @@ "docsPath": "/channels/reef", "docsLabel": "reef", "blurb": "Guarded end-to-end encrypted claw messaging.", - "systemImage": "message.badge" + "systemImage": "message.badge", + "setup": { "fields": [] } } }, "devDependencies": { diff --git a/extensions/reef/src/channel.ts b/extensions/reef/src/channel.ts index 2b3bfbc0dfb5..075b99d338d4 100644 --- a/extensions/reef/src/channel.ts +++ b/extensions/reef/src/channel.ts @@ -31,7 +31,7 @@ import { } from "./owner-notice.js"; import { isRephrasedReefResend } from "./rejection-resend.js"; import { getActiveReef, getOptionalReefRuntime, getReefRuntime, setActiveReef } from "./runtime.js"; -import { reefSetupAdapter, reefSetupWizard } from "./setup.js"; +import { reefSetupAdapter, reefSetupContract, reefSetupWizard } from "./setup.js"; import { assertReefIdentityBinding, loadKeys, openStores, ReefInboxCursorStore } from "./state.js"; import { ReefInboxConnection, @@ -116,6 +116,7 @@ export const reefPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.reef"] }, configSchema: buildChannelConfigSchema(ReefChannelConfigSchema), setup: reefSetupAdapter, + setupContract: reefSetupContract, setupWizard: reefSetupWizard as never, config: { listAccountIds: () => ["default"], diff --git a/extensions/reef/src/setup.ts b/extensions/reef/src/setup.ts index 16ba0306a39e..0ca9d330997a 100644 --- a/extensions/reef/src/setup.ts +++ b/extensions/reef/src/setup.ts @@ -1,3 +1,4 @@ +import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; import { fingerprint } from "../protocol/index.js"; import { @@ -55,6 +56,11 @@ export const reefSetupAdapter = { }) as OpenClawConfig, }; +export const reefSetupContract = defineChannelSetupContract({ + fields: {}, + adapter: reefSetupAdapter, +}); + export const reefSetupWizard = { channel: "reef", getStatus: async ({ cfg }: { cfg: OpenClawConfig }) => { diff --git a/extensions/signal/api.ts b/extensions/signal/api.ts index 2a33ef2885de..f932bc9eb45c 100644 --- a/extensions/signal/api.ts +++ b/extensions/signal/api.ts @@ -64,3 +64,11 @@ export { type SignalSendResult, } from "./src/send.js"; export { normalizeSignalAccountInput } from "./src/setup-core.js"; +export { + detectSignalTransport, + prepareSignalManagedNativeTransport, + probeSignalTransport, + type SignalManagedNativeTransport, + type SignalTransportProbeResult, + writeSignalAccountTransport, +} from "./src/setup-transport.js"; diff --git a/extensions/signal/doctor-contract-api.test.ts b/extensions/signal/doctor-contract-api.test.ts index 7f99c9a5043e..3c7e42a675de 100644 --- a/extensions/signal/doctor-contract-api.test.ts +++ b/extensions/signal/doctor-contract-api.test.ts @@ -1,8 +1,9 @@ // Signal tests cover doctor contract api plugin behavior. import { expectDefined } from "@openclaw/normalization-core"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract-api.js"; +import { migrateLegacySignalTransportConfig } from "./src/config-compat.js"; function signalConfig(entry: Record): OpenClawConfig { return { channels: { signal: entry } } as never; @@ -25,6 +26,27 @@ describe("signal streaming legacy config rules", () => { accountRule?.match?.({ personal: { streaming: { block: { enabled: true } } } }, {}), ).toBe(false); }); + + it("matches retired transport fields at root and account level", () => { + const transportRootRule = legacyConfigRules.find( + (rule) => rule.path.join(".") === "channels.signal" && rule.message.includes("account-owned"), + ); + const transportAccountRule = legacyConfigRules.find( + (rule) => + rule.path.join(".") === "channels.signal.accounts" && + rule.message.includes("account-owned"), + ); + + expect(transportRootRule?.match?.({ apiMode: "auto", httpUrl: "signal:8080" }, {})).toBe(true); + expect(transportRootRule?.match?.({ transport: { kind: "managed-native" } }, {})).toBe(false); + expect(transportAccountRule?.match?.({ work: { httpPort: 8181 } }, {})).toBe(true); + expect( + transportAccountRule?.match?.( + { work: { transport: { kind: "managed-native", httpPort: 8181 } } }, + {}, + ), + ).toBe(false); + }); }); describe("signal normalizeCompatibilityConfig streaming aliases", () => { @@ -76,3 +98,748 @@ describe("signal normalizeCompatibilityConfig streaming aliases", () => { expect(second.config).toBe(first.config); }); }); + +describe("signal transport compatibility", () => { + it("migrates an explicit container mode and materializes named account ownership", () => { + const result = normalizeCompatibilityConfig({ + cfg: signalConfig({ + apiMode: "container", + httpUrl: "http://signal:8080", + accounts: { work: { account: "+15555550123" } }, + }), + }); + const signal = result.config.channels?.signal; + + expect(signal?.transport).toBeUndefined(); + expect(signal?.accounts?.work?.transport).toEqual({ + kind: "container", + url: "http://signal:8080", + }); + expect(signal).not.toHaveProperty("apiMode"); + expect(signal).not.toHaveProperty("httpUrl"); + }); + + it("migrates managed native process fields into transport", () => { + const result = normalizeCompatibilityConfig({ + cfg: signalConfig({ + cliPath: "/opt/signal-cli", + configPath: "/var/lib/signal", + httpPort: 8181, + ignoreAttachments: true, + }), + }); + + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "managed-native", + cliPath: "/opt/signal-cli", + configPath: "/var/lib/signal", + httpPort: 8181, + }); + expect(result.config.channels?.signal?.ignoreAttachments).toBe(true); + expect(result.config.channels?.signal).not.toHaveProperty("cliPath"); + }); + + it("moves a sync-migrated accounts.default transport to the canonical root", () => { + const result = normalizeCompatibilityConfig({ + cfg: signalConfig({ + apiMode: "native", + accounts: { + default: { + account: "+15555550123", + httpPort: 8181, + }, + }, + }), + }); + + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "managed-native", + httpPort: 8181, + }); + expect(result.config.channels?.signal?.accounts?.default).not.toHaveProperty("transport"); + }); + + it("moves an async-migrated accounts.default transport to the canonical root", async () => { + const result = await migrateLegacySignalTransportConfig({ + cfg: signalConfig({ + apiMode: "native", + accounts: { + default: { + account: "+15555550123", + httpPort: 8282, + }, + }, + }), + }); + + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "managed-native", + httpPort: 8282, + }); + expect(result.config.channels?.signal?.accounts?.default).not.toHaveProperty("transport"); + }); + + it("uses a shipped accounts.default endpoint override during sync migration", () => { + const result = normalizeCompatibilityConfig({ + cfg: signalConfig({ + apiMode: "native", + account: "+15555550123", + httpUrl: "http://shadowed-root:8080", + autoStart: false, + accounts: { + default: { + httpUrl: "http://active-default:8181", + }, + }, + }), + }); + + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "external-native", + url: "http://active-default:8181", + }); + expect(result.config.channels?.signal?.accounts?.default).not.toHaveProperty("transport"); + }); + + it("uses a case-preserving accounts.Default endpoint override during migration", () => { + const result = normalizeCompatibilityConfig({ + cfg: signalConfig({ + apiMode: "native", + account: "+15555550123", + httpUrl: "http://shadowed-root:8080", + autoStart: false, + accounts: { + Default: { + account: "+15555550124", + httpUrl: "http://active-default:8181", + autoStart: false, + }, + }, + }), + }); + + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "external-native", + url: "http://active-default:8181", + }); + expect(result.config.channels?.signal?.accounts?.Default).not.toHaveProperty("transport"); + }); + + it("does not probe a shadowed root endpoint during async migration", async () => { + const detect = vi.fn(async ({ url }: { url: string }) => ({ + kind: "external-native" as const, + url, + })); + const result = await migrateLegacySignalTransportConfig({ + cfg: signalConfig({ + apiMode: "auto", + account: "+15555550123", + httpUrl: "http://shadowed-root:8080", + accounts: { + default: { + httpUrl: "http://active-default:8181", + }, + }, + }), + detect, + }); + + expect(detect).toHaveBeenCalledTimes(1); + expect(detect).toHaveBeenCalledWith({ + url: "http://active-default:8181", + account: "+15555550123", + }); + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "external-native", + url: "http://active-default:8181", + }); + }); + + it("preserves the root managed port when accounts.default inherits it", () => { + const result = normalizeCompatibilityConfig({ + cfg: signalConfig({ + apiMode: "native", + account: "+15555550123", + httpPort: 8181, + accounts: { default: {} }, + }), + }); + + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "managed-native", + httpPort: 8181, + }); + expect(result.config.channels?.signal?.accounts?.default).not.toHaveProperty("transport"); + }); + + it("keeps a canonical root transport authoritative while migrating accounts.default", async () => { + const result = await migrateLegacySignalTransportConfig({ + cfg: signalConfig({ + apiMode: "native", + transport: { kind: "external-native", url: "http://canonical-native:8181" }, + accounts: { default: { account: "+15555550123" } }, + }), + }); + + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "external-native", + url: "http://canonical-native:8181", + }); + expect(result.config.channels?.signal?.accounts?.default).not.toHaveProperty("transport"); + }); + + it("keeps attachment suppression account-owned for external transports", () => { + const result = normalizeCompatibilityConfig({ + cfg: signalConfig({ + apiMode: "container", + httpUrl: "signal:8080", + account: "+15555550123", + ignoreAttachments: true, + }), + }); + + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "container", + url: "http://signal:8080", + }); + expect(result.config.channels?.signal?.ignoreAttachments).toBe(true); + }); + + it("defers an explicit account-less container migration", () => { + const cfg = signalConfig({ + apiMode: "container", + httpUrl: "http://signal:8080", + }); + const result = normalizeCompatibilityConfig({ cfg }); + + expect(result.config).toBe(cfg); + expect(result.changes).toEqual([]); + expect(result.warnings).toEqual([ + "- channels.signal: legacy container transport requires an account number; add channels.signal.account (or the relevant channels.signal.accounts.*.account) and rerun openclaw doctor --fix.", + ]); + }); + + it("migrates a disabled account-less container without blocking upgrade", () => { + const result = normalizeCompatibilityConfig({ + cfg: signalConfig({ + enabled: false, + apiMode: "container", + httpUrl: "http://signal:8080", + }), + }); + + expect(result.warnings).toBeUndefined(); + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "container", + url: "http://signal:8080", + }); + expect(result.config.channels?.signal).not.toHaveProperty("apiMode"); + expect(result.config.channels?.signal).not.toHaveProperty("httpUrl"); + }); + + it("preserves an inherited custom port for the first named managed account", () => { + const result = normalizeCompatibilityConfig({ + cfg: signalConfig({ + httpPort: 8181, + accounts: { + work: { account: "+15555550123" }, + alerts: { account: "+15555550124" }, + }, + }), + }); + + expect(result.config.channels?.signal?.transport).toBeUndefined(); + expect(result.config.channels?.signal?.accounts?.work?.transport).toMatchObject({ + kind: "managed-native", + httpPort: 8181, + }); + expect(result.config.channels?.signal?.accounts?.alerts?.transport).toMatchObject({ + kind: "managed-native", + httpPort: 8080, + }); + }); + + it("allocates distinct managed ports while materializing named account ownership", () => { + const result = normalizeCompatibilityConfig({ + cfg: signalConfig({ + account: "+15555550123", + httpPort: 8080, + accounts: { work: { account: "+15555550124" } }, + }), + }); + + expect(result.config.channels?.signal?.transport).toMatchObject({ + kind: "managed-native", + httpPort: 8080, + }); + expect(result.config.channels?.signal?.accounts?.work?.transport).toMatchObject({ + kind: "managed-native", + httpPort: 8081, + }); + }); + + it("keeps migrated managed connection URLs aligned with reassigned bind ports", () => { + const result = normalizeCompatibilityConfig({ + cfg: signalConfig({ + apiMode: "native", + autoStart: true, + account: "+15555550123", + httpHost: "0.0.0.0", + httpPort: 8080, + httpUrl: "http://127.0.0.1:8080", + accounts: { work: { account: "+15555550124" } }, + }), + }); + + expect(result.config.channels?.signal?.transport).toMatchObject({ + kind: "managed-native", + url: "http://127.0.0.1:8080", + httpPort: 8080, + }); + expect(result.config.channels?.signal?.accounts?.work?.transport).toMatchObject({ + kind: "managed-native", + url: "http://127.0.0.1:8081", + httpPort: 8081, + }); + }); + + it("reserves managed bind and local connection ports during migration", () => { + const result = normalizeCompatibilityConfig({ + cfg: signalConfig({ + apiMode: "native", + autoStart: true, + account: "+15555550123", + httpHost: "0.0.0.0", + httpPort: 8181, + httpUrl: "http://localhost:8080", + accounts: { work: { account: "+15555550124" } }, + }), + }); + + expect(result.config.channels?.signal?.transport).toMatchObject({ + kind: "managed-native", + url: "http://localhost:8080", + httpPort: 8181, + }); + expect(result.config.channels?.signal?.accounts?.work?.transport).toMatchObject({ + kind: "managed-native", + url: "http://localhost:8080", + httpPort: 8081, + }); + }); + + it("defers auto endpoint migration until doctor can detect it", () => { + const cfg = signalConfig({ apiMode: "auto", httpUrl: "http://signal:8080" }); + const result = normalizeCompatibilityConfig({ cfg }); + + expect(result.config).toBe(cfg); + expect(result.changes).toEqual([]); + }); + + it("does not configure an account from non-configuring legacy options", () => { + const result = normalizeCompatibilityConfig({ + cfg: signalConfig({ + apiMode: "native", + startupTimeoutMs: 60_000, + receiveMode: "manual", + ignoreStories: true, + }), + }); + + expect(result.config.channels?.signal).not.toHaveProperty("transport"); + expect(result.config.channels?.signal).not.toHaveProperty("apiMode"); + expect(result.config.channels?.signal).not.toHaveProperty("startupTimeoutMs"); + expect(result.config.channels?.signal).not.toHaveProperty("receiveMode"); + expect(result.config.channels?.signal).not.toHaveProperty("ignoreStories"); + }); + + it("detects an auto endpoint once and persists the concrete result", async () => { + const detect = vi.fn().mockResolvedValue({ + kind: "external-native", + url: "http://signal:8080", + }); + const result = await migrateLegacySignalTransportConfig({ + cfg: signalConfig({ + apiMode: "auto", + httpUrl: "http://signal:8080/", + account: "+15555550123", + }), + detect, + }); + + expect(detect).toHaveBeenCalledWith({ + url: "http://signal:8080", + account: "+15555550123", + }); + expect(detect).toHaveBeenCalledTimes(1); + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "external-native", + url: "http://signal:8080", + }); + expect(result.config.channels?.signal).not.toHaveProperty("apiMode"); + + const second = await migrateLegacySignalTransportConfig({ cfg: result.config, detect }); + expect(second.config).toBe(result.config); + expect(second.changes).toEqual([]); + expect(detect).toHaveBeenCalledTimes(1); + }); + + it("defers an account-less auto endpoint detected as a container", async () => { + const detect = vi.fn().mockResolvedValue({ + kind: "container", + url: "http://signal:8080", + }); + const cfg = signalConfig({ + apiMode: "auto", + autoStart: false, + httpUrl: "http://signal:8080", + }); + const result = await migrateLegacySignalTransportConfig({ + cfg, + detect, + }); + + expect(detect).toHaveBeenCalledTimes(1); + expect(result.config).toBe(cfg); + expect(result.changes).toEqual([]); + expect(result.warnings).toEqual([ + "- channels.signal: legacy container transport requires an account number; add channels.signal.account (or the relevant channels.signal.accounts.*.account) and rerun openclaw doctor --fix.", + ]); + }); + + it("detects explicit auto endpoints even when legacy autoStart is true", async () => { + const detect = vi.fn().mockResolvedValue({ + kind: "container", + url: "http://signal:8080", + }); + const result = await migrateLegacySignalTransportConfig({ + cfg: signalConfig({ + apiMode: "auto", + autoStart: true, + httpUrl: "http://signal:8080", + account: "+15555550123", + }), + detect, + }); + + expect(detect).toHaveBeenCalledTimes(1); + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "container", + url: "http://signal:8080", + }); + }); + + it("keeps an auto-detected native endpoint managed when autoStart is true", async () => { + const detect = vi.fn().mockResolvedValue({ + kind: "external-native", + url: "http://127.0.0.1:8181", + }); + const result = await migrateLegacySignalTransportConfig({ + cfg: signalConfig({ + apiMode: "auto", + autoStart: true, + httpUrl: "http://127.0.0.1:8181", + httpHost: "0.0.0.0", + httpPort: 8181, + }), + detect, + }); + + expect(detect).toHaveBeenCalledTimes(1); + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "managed-native", + url: "http://127.0.0.1:8181", + httpHost: "0.0.0.0", + httpPort: 8181, + }); + }); + + it("detects auto host and port endpoints before choosing a concrete transport", async () => { + const detect = vi.fn().mockResolvedValue({ + kind: "container", + url: "http://signal:8181", + }); + const result = await migrateLegacySignalTransportConfig({ + cfg: signalConfig({ + apiMode: "auto", + autoStart: false, + httpHost: "signal", + httpPort: 8181, + account: "+15555550123", + }), + detect, + }); + + expect(detect).toHaveBeenCalledWith({ + url: "http://signal:8181", + account: "+15555550123", + }); + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "container", + url: "http://signal:8181", + }); + }); + + it("keeps a supplemental root UUID from creating a default transport", async () => { + const result = await migrateLegacySignalTransportConfig({ + cfg: signalConfig({ + accountUuid: "123e4567-e89b-12d3-a456-426614174000", + apiMode: "native", + autoStart: true, + httpPort: 8181, + accounts: { + work: { account: "+15555550123" }, + }, + }), + }); + + expect(result.config.channels?.signal?.transport).toBeUndefined(); + expect(result.config.channels?.signal?.accountUuid).toBe( + "123e4567-e89b-12d3-a456-426614174000", + ); + expect(result.config.channels?.signal?.accounts?.work?.transport).toEqual({ + kind: "managed-native", + httpPort: 8181, + }); + }); + + it("keeps explicit native auto-start endpoints managed", async () => { + const result = await migrateLegacySignalTransportConfig({ + cfg: signalConfig({ + apiMode: "native", + autoStart: true, + httpHost: "127.0.0.1", + httpPort: 8181, + httpUrl: "http://127.0.0.1:8181", + }), + }); + + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "managed-native", + httpHost: "127.0.0.1", + httpPort: 8181, + }); + }); + + it("ignores legacy native URL paths when the daemon bind matches", async () => { + const result = await migrateLegacySignalTransportConfig({ + cfg: signalConfig({ + apiMode: "native", + autoStart: true, + httpHost: "127.0.0.1", + httpPort: 8181, + httpUrl: "http://127.0.0.1:8181/proxy", + }), + }); + + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "managed-native", + httpHost: "127.0.0.1", + httpPort: 8181, + }); + }); + + it("preserves an independent managed connection URL", async () => { + const result = await migrateLegacySignalTransportConfig({ + cfg: signalConfig({ + apiMode: "native", + autoStart: true, + httpHost: "0.0.0.0", + httpPort: 8181, + httpUrl: "http://127.0.0.1:8181", + }), + }); + + expect(result.warnings).toBeUndefined(); + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "managed-native", + url: "http://127.0.0.1:8181", + httpHost: "0.0.0.0", + httpPort: 8181, + }); + }); + + it("defers malformed legacy HTTP URLs instead of aborting doctor", async () => { + const cfg = signalConfig({ + apiMode: "native", + autoStart: true, + httpUrl: "http://[bad", + }); + + expect(() => normalizeCompatibilityConfig({ cfg })).not.toThrow(); + const result = await migrateLegacySignalTransportConfig({ cfg }); + + expect(result.config).toBe(cfg); + expect(result.changes).toEqual([]); + expect(result.warnings).toEqual([ + "- channels.signal: legacy httpUrl is invalid; keep the current config, correct httpUrl, then run openclaw doctor --fix.", + ]); + }); + + it("defers legacy managed ports outside the canonical range", async () => { + const cfg = signalConfig({ + apiMode: "native", + autoStart: true, + httpPort: 70_000, + }); + const result = await migrateLegacySignalTransportConfig({ cfg }); + + expect(result.config).toBe(cfg); + expect(result.changes).toEqual([]); + expect(result.warnings).toEqual([ + "- channels.signal: legacy httpPort must be an integer between 1 and 65535; correct httpPort, then run openclaw doctor --fix.", + ]); + }); + + it("defers invalid ports used to derive external legacy endpoints", async () => { + const cfg = signalConfig({ + apiMode: "container", + httpHost: "127.0.0.1", + httpPort: 70_000, + }); + const result = await migrateLegacySignalTransportConfig({ cfg }); + + expect(result.config).toBe(cfg); + expect(result.changes).toEqual([]); + expect(result.warnings).toEqual([ + "- channels.signal: legacy httpPort must be an integer between 1 and 65535; correct httpPort, then run openclaw doctor --fix.", + ]); + }); + + it.each(["bad host", "signal.example/proxy", "signal.example?mode=native"])( + "defers malformed host %s used to derive legacy endpoints", + async (httpHost) => { + const cfg = signalConfig({ + apiMode: "native", + autoStart: true, + httpHost, + }); + + expect(() => normalizeCompatibilityConfig({ cfg })).not.toThrow(); + const result = await migrateLegacySignalTransportConfig({ cfg }); + + expect(result.config).toBe(cfg); + expect(result.changes).toEqual([]); + expect(result.warnings).toEqual([ + "- channels.signal: legacy httpHost is invalid; keep the current config, correct httpHost, then run openclaw doctor --fix.", + ]); + }, + ); + + it("ignores a retired bind port when an explicit container URL owns the endpoint", async () => { + const result = await migrateLegacySignalTransportConfig({ + cfg: { + channels: { + signal: { + account: "+15555550123", + apiMode: "container", + httpUrl: "http://signal-container:8080", + httpPort: 70_000, + }, + }, + } as never, + }); + + expect(result.warnings).toBeUndefined(); + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "container", + url: "http://signal-container:8080", + }); + expect(result.config.channels?.signal).not.toHaveProperty("httpPort"); + }); + + it("keeps canonical transport authoritative over retired malformed URLs", async () => { + const result = await migrateLegacySignalTransportConfig({ + cfg: signalConfig({ + transport: { kind: "external-native", url: "http://canonical-native:8181" }, + apiMode: "native", + httpUrl: "http://[bad", + }), + }); + + expect(result.warnings).toBeUndefined(); + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "external-native", + url: "http://canonical-native:8181", + }); + expect(result.config.channels?.signal).not.toHaveProperty("apiMode"); + expect(result.config.channels?.signal).not.toHaveProperty("httpUrl"); + }); + + it("ignores discarded root transport state for named-account migration", async () => { + const result = await migrateLegacySignalTransportConfig({ + cfg: signalConfig({ + apiMode: "container", + httpUrl: "http://[bad", + accounts: { + work: { account: "+15555550123", httpUrl: "http://signal-work:8080" }, + alerts: { account: "+15555550124", httpUrl: "http://signal-alerts:8080" }, + }, + }), + }); + + expect(result.warnings).toBeUndefined(); + expect(result.config.channels?.signal?.transport).toBeUndefined(); + expect(result.config.channels?.signal?.accounts?.work?.transport).toEqual({ + kind: "container", + url: "http://signal-work:8080", + }); + expect(result.config.channels?.signal?.accounts?.alerts?.transport).toEqual({ + kind: "container", + url: "http://signal-alerts:8080", + }); + }); + + it("defers a malformed root URL inherited by named accounts", async () => { + const cfg = signalConfig({ + apiMode: "container", + httpUrl: "http://[bad", + accounts: { + work: { account: "+15555550123" }, + }, + }); + const result = await migrateLegacySignalTransportConfig({ cfg }); + + expect(result.config).toBe(cfg); + expect(result.changes).toEqual([]); + expect(result.warnings).toEqual([ + "- channels.signal: legacy httpUrl is invalid; keep the current config, correct httpUrl, then run openclaw doctor --fix.", + ]); + }); + + it("keeps an unreachable ambiguous auto endpoint pending with an explicit selection path", async () => { + const cfg = signalConfig({ apiMode: "auto", httpUrl: "http://offline:8080" }); + const result = await migrateLegacySignalTransportConfig({ + cfg, + detect: vi.fn().mockRejectedValue(new Error("offline")), + }); + + expect(result.config).toBe(cfg); + expect(result.changes).toEqual([]); + expect(result.warnings).toEqual([ + "- channels.signal: legacy auto transport is ambiguous while its endpoint is unavailable; bring the endpoint online and rerun openclaw doctor --fix, or replace the retired fields with an explicit account-owned transport in openclaw.json.", + ]); + }); + + it("preserves managed ownership when an explicit auto-start endpoint is offline", async () => { + const detect = vi.fn().mockRejectedValue(new Error("offline")); + const result = await migrateLegacySignalTransportConfig({ + cfg: signalConfig({ + apiMode: "auto", + autoStart: true, + httpUrl: "http://127.0.0.1:8181", + httpHost: "127.0.0.1", + httpPort: 8181, + }), + detect, + }); + + expect(detect).toHaveBeenCalledTimes(1); + expect(result.config.channels?.signal?.transport).toEqual({ + kind: "managed-native", + httpHost: "127.0.0.1", + httpPort: 8181, + }); + expect(result.warnings).toBeUndefined(); + }); +}); diff --git a/extensions/signal/doctor-contract-api.ts b/extensions/signal/doctor-contract-api.ts index fe6b5380f6e9..69de515c5666 100644 --- a/extensions/signal/doctor-contract-api.ts +++ b/extensions/signal/doctor-contract-api.ts @@ -5,6 +5,34 @@ import type { } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { defineChannelAliasMigration } from "openclaw/plugin-sdk/runtime-doctor"; +import { migrateLegacySignalTransportConfigSync } from "./src/config-compat.js"; + +const RETIRED_SIGNAL_ACCOUNT_TRANSPORT_FIELDS = [ + "configPath", + "httpUrl", + "httpHost", + "httpPort", + "cliPath", + "autoStart", + "startupTimeoutMs", + "receiveMode", + "ignoreStories", +] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasRetiredSignalAccountTransportFields(value: unknown): boolean { + return ( + isRecord(value) && + RETIRED_SIGNAL_ACCOUNT_TRANSPORT_FIELDS.some((field) => Object.hasOwn(value, field)) + ); +} + +function hasRetiredSignalAccountMapTransportFields(value: unknown): boolean { + return isRecord(value) && Object.values(value).some(hasRetiredSignalAccountTransportFields); +} // Signal's nested streaming schema is delivery-only ({chunkMode, block}); it // has no preview mode, so only the delivery flat aliases are legal legacy @@ -17,13 +45,34 @@ const streamingAliasMigration = defineChannelAliasMigration({ accountStreamingReplacesRoot: true, }); -export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = - streamingAliasMigration.legacyConfigRules; +export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [ + ...streamingAliasMigration.legacyConfigRules, + { + path: ["channels", "signal"], + message: + 'Signal transport config is now account-owned; run "openclaw doctor --fix" to migrate retired channels.signal transport fields.', + match: (value) => + isRecord(value) && + (Object.hasOwn(value, "apiMode") || hasRetiredSignalAccountTransportFields(value)), + }, + { + path: ["channels", "signal", "accounts"], + message: + 'Signal transport config is now account-owned; run "openclaw doctor --fix" to migrate retired per-account transport fields.', + match: hasRetiredSignalAccountMapTransportFields, + }, +]; export function normalizeCompatibilityConfig({ cfg, }: { cfg: OpenClawConfig; }): ChannelDoctorConfigMutation { - return streamingAliasMigration.normalizeChannelConfig({ cfg }); + const streaming = streamingAliasMigration.normalizeChannelConfig({ cfg }); + const transport = migrateLegacySignalTransportConfigSync(streaming.config); + return { + config: transport.config, + changes: [...streaming.changes, ...transport.changes], + ...(transport.warnings?.length ? { warnings: transport.warnings } : {}), + }; } diff --git a/extensions/signal/package.json b/extensions/signal/package.json index f55696df555c..206696f2e83f 100644 --- a/extensions/signal/package.json +++ b/extensions/signal/package.json @@ -29,28 +29,59 @@ "blurb": "signal-cli linked device; more setup (David Reagans: \"Hop on Discord.\").", "systemImage": "antenna.radiowaves.left.and.right", "markdownCapable": true, - "cliAddOptions": [ - { - "flags": "--cli-path ", - "description": "Channel CLI path" - }, - { - "flags": "--http-url ", - "description": "Channel HTTP service URL" - }, - { - "flags": "--signal-number ", - "description": "Signal account number (E.164)" - }, - { - "flags": "--http-host ", - "description": "Signal HTTP daemon host" - }, - { - "flags": "--http-port ", - "description": "Signal HTTP daemon port" - } - ] + "setup": { + "fields": [ + { + "key": "signalNumber", + "kind": "string", + "cli": { + "flags": "--signal-number ", + "description": "Signal account number (E.164)" + } + }, + { + "key": "signalTransport", + "kind": "choice", + "choices": ["external-native", "container"], + "cli": { + "flags": "--signal-transport ", + "description": "Signal HTTP transport (external-native or container)" + } + }, + { + "key": "cliPath", + "kind": "string", + "cli": { + "flags": "--cli-path ", + "description": "signal-cli executable path" + } + }, + { + "key": "httpUrl", + "kind": "string", + "cli": { + "flags": "--http-url ", + "description": "Signal HTTP service URL" + } + }, + { + "key": "httpHost", + "kind": "string", + "cli": { + "flags": "--http-host ", + "description": "Signal HTTP daemon host" + } + }, + { + "key": "httpPort", + "kind": "string", + "cli": { + "flags": "--http-port ", + "description": "Signal HTTP daemon port" + } + } + ] + } }, "install": { "clawhubSpec": "clawhub:@openclaw/signal", diff --git a/extensions/signal/src/account-types.ts b/extensions/signal/src/account-types.ts index 9d5304d32746..6cc717951857 100644 --- a/extensions/signal/src/account-types.ts +++ b/extensions/signal/src/account-types.ts @@ -1,7 +1,8 @@ // Signal plugin module implements account types behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -export type SignalAccountConfig = Omit< - Exclude["signal"], undefined>, - "accounts" ->; +type SignalChannelConfig = Exclude["signal"], undefined>; + +export type SignalAccountConfig = Omit; + +export type SignalTransportConfig = NonNullable; diff --git a/extensions/signal/src/accounts.test.ts b/extensions/signal/src/accounts.test.ts index 49c94a423e1b..11853a6d3627 100644 --- a/extensions/signal/src/accounts.test.ts +++ b/extensions/signal/src/accounts.test.ts @@ -7,6 +7,388 @@ import { } from "./accounts.js"; describe("resolveSignalAccount", () => { + it("resolves an omitted transport to managed native defaults", () => { + const resolved = resolveSignalAccount({ cfg: { channels: { signal: {} } } as never }); + + expect(resolved.transport).toEqual({ + kind: "managed-native", + baseUrl: "http://127.0.0.1:8080", + cliPath: "signal-cli", + httpHost: "127.0.0.1", + httpPort: 8080, + startupTimeoutMs: 30_000, + }); + }); + + it("uses a managed native connection URL independently from its daemon bind", () => { + const resolved = resolveSignalAccount({ + cfg: { + channels: { + signal: { + transport: { + kind: "managed-native", + url: "http://127.0.0.1:8181", + httpHost: "0.0.0.0", + httpPort: 8181, + }, + }, + }, + } as never, + }); + + expect(resolved.transport).toMatchObject({ + kind: "managed-native", + baseUrl: "http://127.0.0.1:8181", + httpHost: "0.0.0.0", + httpPort: 8181, + }); + }); + + it("formats an IPv6 managed native bind as a valid connection URL", () => { + const resolved = resolveSignalAccount({ + cfg: { + channels: { + signal: { + transport: { + kind: "managed-native", + httpHost: "::1", + httpPort: 8181, + }, + }, + }, + } as never, + }); + + expect(resolved.transport).toMatchObject({ + kind: "managed-native", + baseUrl: "http://[::1]:8181", + httpHost: "::1", + httpPort: 8181, + }); + }); + + it("does not reserve a managed transport's own implicit connection endpoint", () => { + const resolved = resolveSignalAccount({ + cfg: { + channels: { + signal: { + transport: { + kind: "managed-native", + url: "http://127.0.0.1:8080", + httpHost: "0.0.0.0", + }, + }, + }, + } as never, + }); + + expect(resolved.transport).toMatchObject({ + kind: "managed-native", + baseUrl: "http://127.0.0.1:8080", + httpHost: "0.0.0.0", + httpPort: 8080, + }); + }); + + it("preserves a same-port connection URL on a different specific bind address", () => { + const resolved = resolveSignalAccount({ + cfg: { + channels: { + signal: { + transport: { + kind: "managed-native", + url: "http://127.0.0.1:8080", + httpHost: "127.0.0.2", + }, + }, + }, + } as never, + }); + + expect(resolved.transport).toMatchObject({ + kind: "managed-native", + baseUrl: "http://127.0.0.1:8080", + httpHost: "127.0.0.2", + httpPort: 8081, + }); + }); + + it("does not inherit the default account transport into named accounts", () => { + const cfg = { + channels: { + signal: { + transport: { + kind: "container", + url: "http://default-container:8080", + }, + accounts: { + work: { + account: "+15555550123", + }, + }, + }, + }, + } as never; + + expect(resolveSignalAccount({ cfg }).transport).toEqual({ + kind: "container", + baseUrl: "http://default-container:8080", + }); + expect(resolveSignalAccount({ cfg, accountId: "work" }).transport).toMatchObject({ + kind: "managed-native", + baseUrl: "http://127.0.0.1:8080", + }); + }); + + it("keeps the root transport authoritative over accounts.default", () => { + const cfg = { + channels: { + signal: { + transport: { kind: "external-native", url: "http://canonical-native:8181" }, + accounts: { + default: { + account: "+15555550123", + transport: { kind: "container", url: "http://stale-container:8080" }, + }, + }, + }, + }, + } as never; + + expect(resolveSignalAccount({ cfg, accountId: "default" }).transport).toEqual({ + kind: "external-native", + baseUrl: "http://canonical-native:8181", + }); + }); + + it("allocates distinct default ports across managed native accounts", () => { + const cfg = { + channels: { + signal: { + accounts: { + personal: { account: "+15555550123", transport: { kind: "managed-native" } }, + work: { account: "+15555550124", transport: { kind: "managed-native" } }, + }, + }, + }, + } as never; + + expect(resolveSignalAccount({ cfg, accountId: "personal" }).transport).toMatchObject({ + kind: "managed-native", + httpPort: 8080, + }); + expect(resolveSignalAccount({ cfg, accountId: "work" }).transport).toMatchObject({ + kind: "managed-native", + httpPort: 8081, + }); + }); + + it("matches case-preserving account keys while allocating implicit ports", () => { + const cfg = { + channels: { + signal: { + accounts: { + alpha: { + account: "+15555550123", + transport: { kind: "managed-native", httpPort: 8080 }, + }, + Ops: { account: "+15555550124", transport: { kind: "managed-native" } }, + }, + }, + }, + } as never; + + expect(resolveSignalAccount({ cfg, accountId: "Ops" }).transport).toMatchObject({ + kind: "managed-native", + httpPort: 8081, + }); + }); + + it("rejects duplicate explicit managed native ports", () => { + const cfg = { + channels: { + signal: { + accounts: { + personal: { + account: "+15555550123", + transport: { kind: "managed-native", httpPort: 8181 }, + }, + work: { + account: "+15555550124", + transport: { kind: "managed-native", httpPort: 8181 }, + }, + }, + }, + }, + } as never; + + expect(() => resolveSignalAccount({ cfg, accountId: "work" })).toThrow( + 'Signal managed native accounts "work" and "personal" both bind port 8181.', + ); + }); + + it("does not let a disabled account block an active managed port", () => { + const cfg = { + channels: { + signal: { + accounts: { + dormant: { + enabled: false, + account: "+15555550123", + transport: { kind: "managed-native", httpPort: 8181 }, + }, + work: { + account: "+15555550124", + transport: { kind: "managed-native", httpPort: 8181 }, + }, + }, + }, + }, + } as never; + + expect(resolveSignalAccount({ cfg, accountId: "work" }).transport).toMatchObject({ + kind: "managed-native", + httpPort: 8181, + }); + }); + + it("does not reserve an implicit managed port for a disabled account", () => { + const cfg = { + channels: { + signal: { + accounts: { + dormant: { + enabled: false, + account: "+15555550123", + transport: { kind: "managed-native" }, + }, + work: { + account: "+15555550124", + transport: { kind: "managed-native" }, + }, + }, + }, + }, + } as never; + + expect(resolveSignalAccount({ cfg, accountId: "work" }).transport).toMatchObject({ + kind: "managed-native", + httpPort: 8080, + }); + }); + + it("rejects an explicit managed port used by a local external endpoint", () => { + const cfg = { + channels: { + signal: { + accounts: { + proxy: { + account: "+15555550123", + transport: { kind: "container", url: "http://localhost:8181" }, + }, + work: { + account: "+15555550124", + transport: { kind: "managed-native", httpPort: 8181 }, + }, + }, + }, + }, + } as never; + + expect(() => resolveSignalAccount({ cfg, accountId: "work" })).toThrow( + 'Signal managed native account "work" binds port 8181, which conflicts with account "proxy" local transport endpoint.', + ); + }); + + it("rejects an explicit managed port used by its own independent local endpoint", () => { + const cfg = { + channels: { + signal: { + account: "+15555550123", + transport: { + kind: "managed-native", + url: "https://127.0.0.1:8181", + httpPort: 8181, + }, + }, + }, + } as never; + + expect(() => resolveSignalAccount({ cfg })).toThrow( + 'Signal managed native account "default" binds port 8181, which conflicts with its local transport endpoint.', + ); + }); + + it("does not let an unconfigured placeholder consume a managed port", () => { + const cfg = { + channels: { + signal: { + accounts: { + placeholder: { enabled: false }, + work: { account: "+15555550124", transport: { kind: "managed-native" } }, + }, + }, + }, + } as never; + + expect(resolveSignalAccount({ cfg, accountId: "work" }).transport).toMatchObject({ + kind: "managed-native", + httpPort: 8080, + }); + }); + + it("keeps an implicit managed connection URL aligned with its allocated bind", () => { + const cfg = { + channels: { + signal: { + transport: { kind: "managed-native", httpPort: 8080 }, + accounts: { + work: { + account: "+15555550124", + transport: { + kind: "managed-native", + url: "http://127.0.0.1:8080", + httpHost: "0.0.0.0", + }, + }, + }, + }, + }, + } as never; + + expect(resolveSignalAccount({ cfg, accountId: "work" }).transport).toMatchObject({ + kind: "managed-native", + baseUrl: "http://127.0.0.1:8081", + httpHost: "0.0.0.0", + httpPort: 8081, + }); + }); + + it("reserves managed bind and local connection ports for implicit accounts", () => { + const cfg = { + channels: { + signal: { + accounts: { + proxy: { + account: "+15555550123", + transport: { + kind: "managed-native", + url: "http://localhost:8080", + httpPort: 8181, + }, + }, + work: { account: "+15555550124", transport: { kind: "managed-native" } }, + }, + }, + }, + } as never; + + expect(resolveSignalAccount({ cfg, accountId: "work" }).transport).toMatchObject({ + kind: "managed-native", + httpPort: 8081, + }); + }); + it("preserves top-level default account when named accounts are configured", () => { const cfg = { channels: { @@ -24,6 +406,55 @@ describe("resolveSignalAccount", () => { expect(resolveSignalAccount({ cfg }).config.account).toBe("+15555550123"); }); + it("deduplicates a case-preserving default account from the implicit root transport", () => { + const cfg = { + channels: { + signal: { + transport: { + kind: "container", + url: "http://signal-container:8080", + }, + accounts: { + Default: { account: "+15555550123" }, + }, + }, + }, + } as never; + + expect(listSignalAccountIds(cfg)).toEqual(["default"]); + expect(resolveSignalAccount({ cfg }).config.account).toBe("+15555550123"); + }); + + it("does not treat accountUuid as an implicit configured default account", () => { + const cfg = { + channels: { + signal: { + accountUuid: "123e4567-e89b-12d3-a456-426614174000", + accounts: { + work: { account: "+15555550123" }, + }, + }, + }, + } as never; + + expect(listSignalAccountIds(cfg)).toEqual(["work"]); + expect(resolveSignalAccount({ cfg, accountId: "default" }).configured).toBe(false); + expect(resolveSignalAccount({ cfg }).accountId).toBe("work"); + }); + + it("keeps accountUuid supplemental when no E.164 account or transport exists", () => { + const cfg = { + channels: { + signal: { + accountUuid: "123e4567-e89b-12d3-a456-426614174000", + }, + }, + } as never; + + expect(listSignalAccountIds(cfg)).toEqual(["default"]); + expect(resolveSignalAccount({ cfg }).configured).toBe(false); + }); + it("uses configured defaultAccount when accountId is omitted", () => { const resolved = resolveSignalAccount({ cfg: { @@ -34,7 +465,10 @@ describe("resolveSignalAccount", () => { work: { name: "Work", account: "+15555550123", - httpUrl: "http://127.0.0.1:9999", + transport: { + kind: "external-native", + url: "http://127.0.0.1:9999", + }, }, }, }, @@ -45,6 +479,10 @@ describe("resolveSignalAccount", () => { expect(resolved.accountId).toBe("work"); expect(resolved.name).toBe("Work"); expect(resolved.baseUrl).toBe("http://127.0.0.1:9999"); + expect(resolved.transport).toEqual({ + kind: "external-native", + baseUrl: "http://127.0.0.1:9999", + }); expect(resolved.config.account).toBe("+15555550123"); expect(resolved.configured).toBe(true); }); diff --git a/extensions/signal/src/accounts.ts b/extensions/signal/src/accounts.ts index 6302ba558926..30f724eb08f1 100644 --- a/extensions/signal/src/accounts.ts +++ b/extensions/signal/src/accounts.ts @@ -1,6 +1,7 @@ // Signal plugin module implements accounts behavior. import { createAccountListHelpers, + DEFAULT_ACCOUNT_ID, normalizeAccountId, resolveAccountEntry, resolveMergedAccountConfig, @@ -8,34 +9,227 @@ import { } from "openclaw/plugin-sdk/account-resolution"; import type { ReplyToMode } from "openclaw/plugin-sdk/config-contracts"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; -import type { SignalAccountConfig } from "./account-types.js"; +import type { SignalAccountConfig, SignalTransportConfig } from "./account-types.js"; +import { + allocateSignalManagedNativePort, + assignSignalManagedNativePort, + DEFAULT_SIGNAL_MANAGED_NATIVE_PORT, + isSignalManagedNativeConnectionUrlForBind, + resolveLocalSignalTransportPort, +} from "./transport-policy.js"; +import { buildSignalTransportHttpUrl } from "./transport-url.js"; + +export type ResolvedSignalTransport = + | { + kind: "managed-native"; + baseUrl: string; + cliPath: string; + configPath?: string; + httpHost: string; + httpPort: number; + startupTimeoutMs: number; + receiveMode?: "on-start" | "manual"; + ignoreStories?: boolean; + } + | { + kind: "external-native" | "container"; + baseUrl: string; + }; export type ResolvedSignalAccount = { accountId: string; enabled: boolean; name?: string; baseUrl: string; + transport: ResolvedSignalTransport; configured: boolean; config: SignalAccountConfig; }; const { listAccountIds, resolveDefaultAccountId } = createAccountListHelpers("signal", { + normalizeAccountId, implicitDefaultAccount: { - channelKeys: ["account"], + channelKeys: ["account", "transport"], }, }); export const listSignalAccountIds = listAccountIds; export const resolveDefaultSignalAccountId = resolveDefaultAccountId; -function mergeSignalAccountConfig(cfg: OpenClawConfig, accountId: string): SignalAccountConfig { - return resolveMergedAccountConfig({ - channelConfig: cfg.channels?.signal as SignalAccountConfig | undefined, +export function resolveSignalAccountConfig( + cfg: OpenClawConfig, + accountId: string, +): SignalAccountConfig { + const channelConfig = cfg.channels?.signal; + const { + transport: _transport, + accounts: _accounts, + defaultAccount: _defaultAccount, + ...shared + } = channelConfig ?? {}; + const merged = resolveMergedAccountConfig({ + channelConfig: (accountId === DEFAULT_ACCOUNT_ID ? channelConfig : shared) as + | SignalAccountConfig + | undefined, accounts: cfg.channels?.signal?.accounts as | Record> | undefined, accountId, nestedObjectKeys: ["aliases"], }); + if (accountId === DEFAULT_ACCOUNT_ID && channelConfig?.transport) { + // Setup and doctor store the default account transport at the channel root. + // A stale nested default copy must not redirect runtime to another daemon. + return { ...merged, transport: channelConfig.transport }; + } + return merged; +} + +function isSignalAccountConfigured(config: SignalAccountConfig): boolean { + return Boolean(normalizeOptionalString(config.account) || config.transport); +} + +function isSignalAccountEnabled(cfg: OpenClawConfig, config: SignalAccountConfig): boolean { + return cfg.channels?.signal?.enabled !== false && config.enabled !== false; +} + +function resolveSignalManagedNativePort(params: { + cfg: OpenClawConfig; + accountId: string; + accountConfig: SignalAccountConfig; + transport: SignalTransportConfig | undefined; +}): number { + if (!isSignalAccountEnabled(params.cfg, params.accountConfig)) { + return params.transport?.kind === "managed-native" && params.transport.httpPort !== undefined + ? params.transport.httpPort + : DEFAULT_SIGNAL_MANAGED_NATIVE_PORT; + } + if (params.transport?.kind === "managed-native" && params.transport.httpPort !== undefined) { + const explicitPort = params.transport.httpPort; + if ( + params.transport.url && + !isSignalManagedNativeConnectionUrlForBind(params.transport) && + resolveLocalSignalTransportPort(params.transport.url) === explicitPort + ) { + throw new Error( + `Signal managed native account "${params.accountId}" binds port ${explicitPort}, which conflicts with its local transport endpoint. Assign a distinct transport.httpPort.`, + ); + } + for (const accountId of listSignalAccountIds(params.cfg)) { + if (normalizeAccountId(accountId) === params.accountId) { + continue; + } + const accountConfig = resolveSignalAccountConfig(params.cfg, accountId); + if ( + !isSignalAccountConfigured(accountConfig) || + !isSignalAccountEnabled(params.cfg, accountConfig) + ) { + continue; + } + const transport = accountConfig.transport; + if (transport?.kind === "managed-native" && transport.httpPort === explicitPort) { + throw new Error( + `Signal managed native accounts "${params.accountId}" and "${accountId}" both bind port ${explicitPort}. Assign each account a distinct transport.httpPort.`, + ); + } + const independentLocalUrl = + transport?.kind === "external-native" || + transport?.kind === "container" || + (transport?.kind === "managed-native" && + Boolean(transport.url) && + !isSignalManagedNativeConnectionUrlForBind(transport)) + ? transport.url + : undefined; + if ( + independentLocalUrl && + resolveLocalSignalTransportPort(independentLocalUrl) === explicitPort + ) { + throw new Error( + `Signal managed native account "${params.accountId}" binds port ${explicitPort}, which conflicts with account "${accountId}" local transport endpoint. Assign a distinct transport.httpPort.`, + ); + } + } + return explicitPort; + } + + const reservedPorts = new Set(); + const implicitManagedAccountIds: string[] = []; + // Reserve concrete local endpoints first, then assign implicit ports in account order. + // Independent account resolution must produce the same collision-free daemon binds. + for (const accountId of listSignalAccountIds(params.cfg)) { + const accountConfig = resolveSignalAccountConfig(params.cfg, accountId); + if ( + !isSignalAccountConfigured(accountConfig) || + !isSignalAccountEnabled(params.cfg, accountConfig) + ) { + continue; + } + const transport = accountConfig.transport; + if (transport?.kind === "external-native" || transport?.kind === "container") { + const localPort = resolveLocalSignalTransportPort(transport.url); + if (localPort !== undefined) { + reservedPorts.add(localPort); + } + continue; + } + if (transport?.kind === "managed-native") { + if (transport.httpPort !== undefined) { + reservedPorts.add(transport.httpPort); + } else { + implicitManagedAccountIds.push(accountId); + } + if (transport.url && !isSignalManagedNativeConnectionUrlForBind(transport)) { + const localConnectionPort = resolveLocalSignalTransportPort(transport.url); + if (localConnectionPort !== undefined) { + reservedPorts.add(localConnectionPort); + } + } + continue; + } + implicitManagedAccountIds.push(accountId); + } + + for (const accountId of implicitManagedAccountIds) { + const port = allocateSignalManagedNativePort({ reservedPorts }); + reservedPorts.add(port); + if (normalizeAccountId(accountId) === params.accountId) { + return port; + } + } + return DEFAULT_SIGNAL_MANAGED_NATIVE_PORT; +} + +export function resolveSignalTransport( + transport: SignalTransportConfig | undefined, + managedNativePort = DEFAULT_SIGNAL_MANAGED_NATIVE_PORT, +): ResolvedSignalTransport { + if (transport?.kind === "external-native" || transport?.kind === "container") { + return { + kind: transport.kind, + baseUrl: transport.url.trim(), + }; + } + + const managedTransport = + transport?.kind === "managed-native" + ? assignSignalManagedNativePort(transport, transport.httpPort ?? managedNativePort) + : transport; + const httpHost = normalizeOptionalString(managedTransport?.httpHost) ?? "127.0.0.1"; + const httpPort = managedTransport?.httpPort ?? managedNativePort; + const configPath = normalizeOptionalString(managedTransport?.configPath); + const connectionUrl = normalizeOptionalString(managedTransport?.url); + return { + kind: "managed-native", + baseUrl: connectionUrl ?? buildSignalTransportHttpUrl(httpHost, httpPort), + cliPath: normalizeOptionalString(managedTransport?.cliPath) ?? "signal-cli", + ...(configPath ? { configPath } : {}), + httpHost, + httpPort, + startupTimeoutMs: managedTransport?.startupTimeoutMs ?? 30_000, + ...(managedTransport?.receiveMode ? { receiveMode: managedTransport.receiveMode } : {}), + ...(typeof managedTransport?.ignoreStories === "boolean" + ? { ignoreStories: managedTransport.ignoreStories } + : {}), + }; } export function resolveSignalAccount(params: { @@ -46,22 +240,26 @@ export function resolveSignalAccount(params: { params.accountId ?? resolveDefaultSignalAccountId(params.cfg), ); const baseEnabled = params.cfg.channels?.signal?.enabled !== false; - const merged = mergeSignalAccountConfig(params.cfg, accountId); + const merged = resolveSignalAccountConfig(params.cfg, accountId); const accountEnabled = merged.enabled !== false; const enabled = baseEnabled && accountEnabled; - const baseUrl = normalizeOptionalString(merged.httpUrl) ?? "http://127.0.0.1:8080"; - const configured = Boolean( - normalizeOptionalString(merged.account) || - normalizeOptionalString(merged.configPath) || - normalizeOptionalString(merged.httpUrl) || - normalizeOptionalString(merged.cliPath) || - typeof merged.autoStart === "boolean", + const transport = resolveSignalTransport( + merged.transport, + resolveSignalManagedNativePort({ + cfg: params.cfg, + accountId, + accountConfig: merged, + transport: merged.transport, + }), ); + const baseUrl = transport.baseUrl; + const configured = isSignalAccountConfigured(merged); return { accountId, enabled, name: normalizeOptionalString(merged.name), baseUrl, + transport, configured, config: merged, }; diff --git a/extensions/signal/src/channel.setup.ts b/extensions/signal/src/channel.setup.ts index 7930a29ec8a7..5dc9115c67f8 100644 --- a/extensions/signal/src/channel.setup.ts +++ b/extensions/signal/src/channel.setup.ts @@ -1,12 +1,12 @@ // Signal plugin module implements channel.setup behavior. import type { ChannelPlugin } from "openclaw/plugin-sdk/core"; import type { ResolvedSignalAccount } from "./accounts.js"; -import { signalSetupAdapter } from "./setup-core.js"; +import { signalSetupContract } from "./setup-core.js"; import { createSignalPluginBase, signalSetupWizard } from "./shared.js"; export const signalSetupPlugin: ChannelPlugin = { ...createSignalPluginBase({ setupWizard: signalSetupWizard, - setup: signalSetupAdapter, + setupContract: signalSetupContract, }), }; diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index 44894f0f0ba0..28df3dd0f256 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -46,7 +46,7 @@ import { resolveSignalOutboundTarget } from "./outbound-session.js"; import { materializeSignalPresentationFallback } from "./presentation-fallback.js"; import { resolveSignalReactionLevel } from "./reaction-level.js"; import { resolveSignalReplyContextWithPersistence } from "./reply-authors.js"; -import { signalSetupAdapter } from "./setup-core.js"; +import { signalSetupContract } from "./setup-core.js"; import { createSignalPluginBase, signalConfigAdapter, @@ -482,7 +482,7 @@ export const signalPlugin: ChannelPlugin = base: { ...createSignalPluginBase({ setupWizard: signalSetupWizard, - setup: signalSetupAdapter, + setupContract: signalSetupContract, }), actions: signalMessageActions, approvalCapability: signalApprovalCapability, @@ -582,7 +582,7 @@ export const signalPlugin: ChannelPlugin = const baseUrl = account.baseUrl; const { probeSignal } = await loadSignalProbeModule(); return await probeSignal(baseUrl, timeoutMs, { - apiMode: account.config?.apiMode ?? "auto", + transportKind: account.transport.kind, }); }, formatCapabilitiesProbe: ({ probe }) => diff --git a/extensions/signal/src/client-adapter.test.ts b/extensions/signal/src/client-adapter.test.ts index 6dc0d623d3ac..914d35133d3b 100644 --- a/extensions/signal/src/client-adapter.test.ts +++ b/extensions/signal/src/client-adapter.test.ts @@ -1,643 +1,142 @@ -// Signal tests cover client adapter plugin behavior. +// Signal tests cover concrete transport routing in the client adapter. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - signalRpcRequest as signalRpcRequestImpl, - signalCheck as signalCheckImpl, - streamSignalEvents as streamSignalEventsImpl, - type SignalApiMode, -} from "./client-adapter.js"; -const { - mockNativeCheck, - mockNativeRpcRequest, - mockNativeStreamEvents, - mockContainerCheck, - mockContainerRpcRequest, - mockStreamContainerEvents, -} = vi.hoisted(() => ({ - mockNativeCheck: vi.fn(), - mockNativeRpcRequest: vi.fn(), - mockNativeStreamEvents: vi.fn(), - mockContainerCheck: vi.fn(), - mockContainerRpcRequest: vi.fn(), - mockStreamContainerEvents: vi.fn(), -})); +import { signalCheck, signalRpcRequest, streamSignalEvents } from "./client-adapter.js"; +import * as containerClient from "./client-container.js"; +import * as nativeClient from "./client.js"; -vi.mock("./client.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - signalCheck: mockNativeCheck, - signalRpcRequest: mockNativeRpcRequest, - streamSignalEvents: mockNativeStreamEvents, - }; +const nativeCheck = vi.fn(); +const nativeRpc = vi.fn(); +const nativeStream = vi.fn(); +const containerCheck = vi.fn(); +const containerRpc = vi.fn(); +const containerStream = vi.fn(); + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(nativeClient, "signalCheck").mockImplementation(nativeCheck); + vi.spyOn(nativeClient, "signalRpcRequest").mockImplementation(nativeRpc); + vi.spyOn(nativeClient, "streamSignalEvents").mockImplementation(nativeStream); + vi.spyOn(containerClient, "containerCheck").mockImplementation(containerCheck); + vi.spyOn(containerClient, "containerRpcRequest").mockImplementation(containerRpc); + vi.spyOn(containerClient, "streamContainerEvents").mockImplementation(containerStream); }); -vi.mock("./client-container.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - containerCheck: mockContainerCheck, - containerRpcRequest: mockContainerRpcRequest, - streamContainerEvents: mockStreamContainerEvents, - }; -}); - -let currentApiMode: SignalApiMode = "auto"; - afterEach(() => { - vi.useRealTimers(); -}); - -function setApiMode(mode: SignalApiMode) { - currentApiMode = mode; -} - -function signalRpcRequest( - method: string, - params: Record | undefined, - opts: Parameters[2], -) { - return signalRpcRequestImpl(method, params, { ...opts, apiMode: currentApiMode }); -} - -function signalCheck(baseUrl: string, timeoutMs?: number) { - return signalCheckImpl(baseUrl, timeoutMs, { apiMode: currentApiMode }); -} - -function streamSignalEvents(params: Parameters[0]) { - return streamSignalEventsImpl({ ...params, apiMode: currentApiMode }); -} - -async function detectSignalApiMode( - baseUrl: string, - timeoutMs = 10_000, - options: { account?: string; requireContainerReceive?: boolean } = {}, -): Promise<"native" | "container"> { - if (options.requireContainerReceive) { - await streamSignalEventsImpl({ - baseUrl, - account: options.account, - timeoutMs, - onEvent: vi.fn(), - apiMode: "auto", - }); - return mockStreamContainerEvents.mock.calls.length > 0 ? "container" : "native"; - } - const result = await signalCheckImpl(baseUrl, timeoutMs, { apiMode: "auto" }); - if (!result.ok) { - throw new Error(result.error ?? `Signal API not reachable at ${baseUrl}`); - } - return mockContainerCheck.mock.calls.length > mockNativeCheck.mock.calls.length - ? "container" - : "native"; -} - -type MockCalls = { - mock: { calls: unknown[][] }; -}; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function requireRecord(value: unknown, label: string): Record { - expect(isRecord(value), `${label} should be an object`).toBe(true); - if (!isRecord(value)) { - throw new Error(`${label} should be an object`); - } - return value; -} - -function expectFields(record: Record, expected: Record) { - for (const [key, value] of Object.entries(expected)) { - expect(record[key], key).toEqual(value); - } -} - -function requireMockCall(mock: MockCalls, label: string, index = 0): unknown[] { - const call = mock.mock.calls.at(index); - if (!call) { - throw new Error(`expected ${label} call ${index}`); - } - return call; -} - -function expectRpcCall(params: { - mock: MockCalls; - method: string; - rpcParams?: Record; - options?: Record; -}) { - expect(params.mock.mock.calls).toHaveLength(1); - const [method, rpcParams, options] = requireMockCall(params.mock, "rpc"); - expect(method).toBe(params.method); - if (params.rpcParams) { - expectFields(requireRecord(rpcParams, "rpc params"), params.rpcParams); - } else if (rpcParams === undefined) { - throw new Error("expected rpc params argument"); - } - if (params.options) { - expectFields(requireRecord(options, "rpc options"), params.options); - } else if (options === undefined) { - throw new Error("expected rpc options argument"); - } -} - -function expectSingleObjectCall(mock: MockCalls, expected: Record) { - expect(mock.mock.calls).toHaveLength(1); - const [payload] = requireMockCall(mock, "single object"); - expectFields(requireRecord(payload, "call payload"), expected); -} - -describe("detectSignalApiMode", () => { - beforeEach(() => { - vi.clearAllMocks(); - setApiMode("native"); - }); - - it("returns native when native endpoint responds", async () => { - mockNativeCheck.mockResolvedValue({ ok: true, status: 200 }); - mockContainerCheck.mockResolvedValue({ ok: false, status: 404 }); - - const result = await detectSignalApiMode("http://native-only.local:8080"); - expect(result).toBe("native"); - }); - - it("returns container when only container endpoint responds", async () => { - mockNativeCheck.mockResolvedValue({ ok: false, status: 404 }); - mockContainerCheck.mockResolvedValue({ ok: true, status: 200 }); - - const result = await detectSignalApiMode("http://container-only.local:8080"); - expect(result).toBe("container"); - }); - - it("prefers native when both endpoints respond", async () => { - mockNativeCheck.mockResolvedValue({ ok: true, status: 200 }); - mockContainerCheck.mockResolvedValue({ ok: true, status: 200 }); - - const result = await detectSignalApiMode("http://both-healthy.local:8080"); - expect(result).toBe("native"); - }); - - it("prefers native even when the container probe resolves first", async () => { - mockNativeCheck.mockImplementation( - () => - new Promise((resolve) => { - setTimeout(() => resolve({ ok: true, status: 200 }), 1); - }), - ); - mockContainerCheck.mockResolvedValue({ ok: true, status: 200 }); - - const result = await detectSignalApiMode("http://container-first.local:8080"); - expect(result).toBe("native"); - }); - - it("returns container after the native preference grace when native does not respond", async () => { - vi.useFakeTimers(); - try { - mockNativeCheck.mockImplementation(() => new Promise(() => {})); - mockContainerCheck.mockResolvedValue({ ok: true, status: 200 }); - - const result = detectSignalApiMode("http://native-stalled.local:8080"); - await Promise.resolve(); - await vi.advanceTimersByTimeAsync(50); - await expect(result).resolves.toBe("container"); - } finally { - vi.useRealTimers(); - } - }); - - it("throws error when neither endpoint responds", async () => { - mockNativeCheck.mockResolvedValue({ ok: false, status: null, error: "Connection refused" }); - mockContainerCheck.mockResolvedValue({ ok: false, status: null, error: "Connection refused" }); - - await expect(detectSignalApiMode("http://neither-healthy.local:8080")).rejects.toThrow( - "Signal API not reachable at http://neither-healthy.local:8080", - ); - }); - - it("handles exceptions from check functions", async () => { - mockNativeCheck.mockRejectedValue(new Error("Network error")); - mockContainerCheck.mockRejectedValue(new Error("Network error")); - - await expect(detectSignalApiMode("http://probe-errors.local:8080")).rejects.toThrow( - "Signal API not reachable", - ); - }); - - it("respects timeout parameter", async () => { - mockNativeCheck.mockResolvedValue({ ok: true, status: 200 }); - mockContainerCheck.mockResolvedValue({ ok: false }); - - await detectSignalApiMode("http://custom-timeout.local:8080", 5000); - expect(mockNativeCheck).toHaveBeenCalledWith("http://custom-timeout.local:8080", 5000); - expect(mockContainerCheck).toHaveBeenCalledWith("http://custom-timeout.local:8080", 5000); - }); - - it("requires a working container receive WebSocket when requested", async () => { - mockNativeCheck.mockResolvedValue({ ok: false, status: 404 }); - mockContainerCheck.mockResolvedValue({ ok: true, status: 101 }); - - const result = await detectSignalApiMode("http://container-receive.local:8080", 5000, { - account: "+14259798283", - requireContainerReceive: true, - }); - - expect(result).toBe("container"); - expect(mockContainerCheck).toHaveBeenCalledWith( - "http://container-receive.local:8080", - 5000, - "+14259798283", - ); - }); - - it("does not select container receive mode without an account", async () => { - mockNativeCheck.mockResolvedValue({ ok: false, status: 404 }); - - await expect( - detectSignalApiMode("http://missing-account.local:8080", 5000, { - requireContainerReceive: true, - }), - ).rejects.toThrow("Signal API not reachable"); - - expect(mockContainerCheck).not.toHaveBeenCalled(); - }); + vi.restoreAllMocks(); }); describe("signalRpcRequest", () => { - beforeEach(() => { - vi.clearAllMocks(); - setApiMode("native"); - }); + it.each(["managed-native", "external-native"] as const)( + "routes %s through native JSON-RPC", + async (transportKind) => { + nativeRpc.mockResolvedValue({ timestamp: 17 }); - it("routes to native JSON-RPC for native mode", async () => { - mockNativeRpcRequest.mockResolvedValue({ timestamp: 1700000000000 }); + await expect( + signalRpcRequest( + "send", + { message: "Hello", recipient: ["+15550001111"] }, + { baseUrl: "http://native:8080", transportKind }, + ), + ).resolves.toEqual({ timestamp: 17 }); + expect(nativeRpc).toHaveBeenCalledWith( + "send", + { message: "Hello", recipient: ["+15550001111"] }, + expect.objectContaining({ baseUrl: "http://native:8080", transportKind }), + ); + expect(containerRpc).not.toHaveBeenCalled(); + }, + ); - const result = await signalRpcRequest( + it("routes container through REST", async () => { + containerRpc.mockResolvedValue({ timestamp: 17 }); + + await expect( + signalRpcRequest( + "send", + { message: "Hello", recipient: ["+15550001111"] }, + { baseUrl: "http://container:8080", transportKind: "container" }, + ), + ).resolves.toEqual({ timestamp: 17 }); + expect(containerRpc).toHaveBeenCalledWith( "send", - { message: "Hello", account: "+14259798283", recipient: ["+15550001111"] }, - { baseUrl: "http://localhost:8080" }, + { message: "Hello", recipient: ["+15550001111"] }, + { baseUrl: "http://container:8080", transportKind: "container" }, ); - - expect(result).toEqual({ timestamp: 1700000000000 }); - expectRpcCall({ - mock: mockNativeRpcRequest, - method: "send", - rpcParams: { message: "Hello" }, - options: { baseUrl: "http://localhost:8080" }, - }); - expect(mockContainerRpcRequest).not.toHaveBeenCalled(); - }); - - it("routes to container RPC for container mode", async () => { - setApiMode("container"); - mockContainerRpcRequest.mockResolvedValue({ timestamp: 1700000000000 }); - - const result = await signalRpcRequest( - "send", - { message: "Hello", account: "+14259798283", recipient: ["+15550001111"] }, - { baseUrl: "http://localhost:8080" }, - ); - - expect(result).toEqual({ timestamp: 1700000000000 }); - expectRpcCall({ - mock: mockContainerRpcRequest, - method: "send", - rpcParams: { message: "Hello" }, - options: { baseUrl: "http://localhost:8080" }, - }); - expect(mockNativeRpcRequest).not.toHaveBeenCalled(); - }); - - it("uses explicit apiMode from the active config", async () => { - setApiMode("native"); - mockContainerRpcRequest.mockResolvedValue({ timestamp: 1700000000000 }); - - const result = await signalRpcRequestImpl( - "send", - { message: "Hello", account: "+14259798283", recipient: ["+15550001111"] }, - { baseUrl: "http://localhost:8080", apiMode: "container" }, - ); - - expect(result).toEqual({ timestamp: 1700000000000 }); - expect(mockContainerRpcRequest).toHaveBeenCalled(); - expect(mockNativeRpcRequest).not.toHaveBeenCalled(); - }); - - it("passes all RPC methods through to native", async () => { - mockNativeRpcRequest.mockResolvedValue({}); - - await signalRpcRequest( - "sendTyping", - { account: "+1", recipient: ["+2"] }, - { baseUrl: "http://localhost:8080" }, - ); - expectRpcCall({ mock: mockNativeRpcRequest, method: "sendTyping" }); - }); - - it("passes all RPC methods through to container", async () => { - setApiMode("container"); - mockContainerRpcRequest.mockResolvedValue({}); - - await signalRpcRequest( - "sendReceipt", - { account: "+1", recipient: ["+2"] }, - { baseUrl: "http://localhost:8080" }, - ); - expectRpcCall({ mock: mockContainerRpcRequest, method: "sendReceipt" }); + expect(nativeRpc).not.toHaveBeenCalled(); }); }); describe("signalCheck", () => { - beforeEach(() => { - vi.clearAllMocks(); - setApiMode("native"); + it("probes only the configured native endpoint", async () => { + nativeCheck.mockResolvedValue({ ok: true, status: 200 }); + + await expect( + signalCheck("http://native:8080", 5_000, { transportKind: "external-native" }), + ).resolves.toEqual({ ok: true, status: 200 }); + expect(nativeCheck).toHaveBeenCalledWith("http://native:8080", 5_000); + expect(containerCheck).not.toHaveBeenCalled(); }); - it("uses native check for native mode", async () => { - mockNativeCheck.mockResolvedValue({ ok: true, status: 200 }); + it("probes only the configured container endpoint", async () => { + containerCheck.mockResolvedValue({ ok: true, status: 200 }); - const result = await signalCheck("http://localhost:8080"); - - expect(result).toEqual({ ok: true, status: 200 }); - expect(mockNativeCheck).toHaveBeenCalledWith("http://localhost:8080", 10000); - expect(mockContainerCheck).not.toHaveBeenCalled(); - }); - - it("uses container check for container mode", async () => { - setApiMode("container"); - mockContainerCheck.mockResolvedValue({ ok: true, status: 200 }); - - const result = await signalCheck("http://localhost:8080"); - - expect(result).toEqual({ ok: true, status: 200 }); - expect(mockContainerCheck).toHaveBeenCalledWith("http://localhost:8080", 10000); - expect(mockNativeCheck).not.toHaveBeenCalled(); - }); - - it("respects timeout parameter", async () => { - mockNativeCheck.mockResolvedValue({ ok: true }); - - await signalCheck("http://localhost:8080", 5000); - - expect(mockNativeCheck).toHaveBeenCalledWith("http://localhost:8080", 5000); - }); - - it("uses the caller timeout for auto detection", async () => { - setApiMode("auto"); - mockNativeCheck.mockResolvedValue({ ok: false, status: null, error: "Connection refused" }); - mockContainerCheck.mockResolvedValue({ ok: false, status: null, error: "Connection refused" }); - - await signalCheck("http://auto-timeout.local:8080", 1000); - - expect(mockNativeCheck).toHaveBeenCalledWith("http://auto-timeout.local:8080", 1000); - expect(mockContainerCheck).toHaveBeenCalledWith("http://auto-timeout.local:8080", 1000); - }); - - it("returns a retryable failure when auto detection is not ready", async () => { - setApiMode("auto"); - mockNativeCheck.mockResolvedValue({ ok: false, status: null, error: "Connection refused" }); - mockContainerCheck.mockResolvedValue({ ok: false, status: null, error: "Connection refused" }); - - await expect(signalCheck("http://localhost:8080")).resolves.toEqual({ - ok: false, - status: null, - error: "Signal API not reachable at http://localhost:8080", - }); - }); - - it("drops cached auto mode when the current clock is not a valid date timestamp", async () => { - setApiMode("auto"); - vi.spyOn(Date, "now").mockReturnValueOnce(1_700_000_000_000).mockReturnValueOnce(Number.NaN); - mockNativeCheck.mockResolvedValue({ ok: true, status: 200 }); - mockContainerCheck.mockResolvedValue({ ok: false, status: 404 }); - - await expect(signalCheck("http://auto-invalid-clock.local:8080")).resolves.toEqual({ - ok: true, - status: 200, - }); - await expect(signalCheck("http://auto-invalid-clock.local:8080")).resolves.toEqual({ - ok: true, - status: 200, - }); - - expect(mockNativeCheck).toHaveBeenCalledTimes(4); - expect(mockContainerCheck).toHaveBeenCalledTimes(2); - }); - - it("does not cache auto mode when the expiry timestamp would exceed the valid date range", async () => { - setApiMode("auto"); - vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000); - mockNativeCheck.mockResolvedValue({ ok: true, status: 200 }); - mockContainerCheck.mockResolvedValue({ ok: false, status: 404 }); - - await expect(signalCheck("http://auto-overflow-clock.local:8080")).resolves.toEqual({ - ok: true, - status: 200, - }); - await expect(signalCheck("http://auto-overflow-clock.local:8080")).resolves.toEqual({ - ok: true, - status: 200, - }); - - expect(mockNativeCheck).toHaveBeenCalledTimes(4); - expect(mockContainerCheck).toHaveBeenCalledTimes(2); + await expect( + signalCheck("http://container:8080", 5_000, { transportKind: "container" }), + ).resolves.toEqual({ ok: true, status: 200 }); + expect(containerCheck).toHaveBeenCalledWith("http://container:8080", 5_000); + expect(nativeCheck).not.toHaveBeenCalled(); }); }); describe("streamSignalEvents", () => { - beforeEach(() => { - vi.clearAllMocks(); - setApiMode("native"); - }); - - it("uses native SSE for native mode", async () => { - mockNativeStreamEvents.mockResolvedValue(undefined); - + it("uses native SSE for managed and external native transports", async () => { + nativeStream.mockImplementation(async (params) => { + params.onEvent({ event: "receive", data: "native" }); + }); const onEvent = vi.fn(); + await streamSignalEvents({ - baseUrl: "http://localhost:8080", - account: "+14259798283", + baseUrl: "http://native:8080", + account: "+15555550123", + transportKind: "managed-native", + timeoutMs: 0, onEvent, }); - expectSingleObjectCall(mockNativeStreamEvents, { - baseUrl: "http://localhost:8080", - account: "+14259798283", - }); - expect(mockStreamContainerEvents).not.toHaveBeenCalled(); + expect(nativeStream).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: "http://native:8080", + account: "+15555550123", + timeoutMs: 0, + }), + ); + expect(onEvent).toHaveBeenCalledWith({ event: "receive", data: "native" }); + expect(containerStream).not.toHaveBeenCalled(); }); - it("uses container WebSocket for container mode", async () => { - setApiMode("container"); - mockStreamContainerEvents.mockResolvedValue(undefined); - + it("uses the container WebSocket and converts its event shape", async () => { + containerStream.mockImplementation(async (params) => { + params.onEvent({ envelope: { sourceNumber: "+15555550124" } }); + }); const onEvent = vi.fn(); + await streamSignalEvents({ - baseUrl: "http://localhost:8080", - account: "+14259798283", + baseUrl: "http://container:8080", + account: "+15555550123", + transportKind: "container", onEvent, }); - expectSingleObjectCall(mockStreamContainerEvents, { - baseUrl: "http://localhost:8080", - account: "+14259798283", - }); - expect(mockNativeStreamEvents).not.toHaveBeenCalled(); - }); - - it("passes native SSE events through unchanged", async () => { - const payload = { envelope: { sourceNumber: "+1555000111" } }; - mockNativeStreamEvents.mockImplementation(async (params) => { - params.onEvent({ event: "receive", data: JSON.stringify(payload) }); - }); - - const events: unknown[] = []; - await streamSignalEvents({ - baseUrl: "http://localhost:8080", - onEvent: (evt) => events.push(evt), - }); - - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ event: "receive", data: JSON.stringify(payload) }); - }); - - it("converts container events to SSE-like receive events", async () => { - setApiMode("container"); - mockStreamContainerEvents.mockImplementation(async (params) => { - params.onEvent({ envelope: { sourceNumber: "+1555000111" } }); - }); - - const events: unknown[] = []; - await streamSignalEvents({ - baseUrl: "http://localhost:8080", - onEvent: (evt) => events.push(evt), - }); - - expect(events).toHaveLength(1); - expect(events[0]).toEqual({ + expect(containerStream).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: "http://container:8080", + account: "+15555550123", + }), + ); + expect(onEvent).toHaveBeenCalledWith({ event: "receive", - data: JSON.stringify({ envelope: { sourceNumber: "+1555000111" } }), + data: JSON.stringify({ envelope: { sourceNumber: "+15555550124" } }), }); - }); - - it("passes abort signal to underlying stream", async () => { - mockNativeStreamEvents.mockResolvedValue(undefined); - - const abortController = new AbortController(); - await streamSignalEvents({ - baseUrl: "http://localhost:8080", - abortSignal: abortController.signal, - onEvent: vi.fn(), - }); - - expectSingleObjectCall(mockNativeStreamEvents, { - abortSignal: abortController.signal, - }); - }); - - it("forwards timeout to native SSE stream", async () => { - mockNativeStreamEvents.mockResolvedValue(undefined); - - await streamSignalEvents({ - baseUrl: "http://localhost:8080", - timeoutMs: 45000, - onEvent: vi.fn(), - }); - - expectSingleObjectCall(mockNativeStreamEvents, { - timeoutMs: 45000, - }); - }); - - it("uses a positive probe timeout while preserving zero stream timeout", async () => { - setApiMode("auto"); - mockNativeCheck.mockResolvedValue({ ok: true, status: 200 }); - mockContainerCheck.mockResolvedValue({ ok: false, status: 404 }); - mockNativeStreamEvents.mockResolvedValue(undefined); - - await streamSignalEvents({ - baseUrl: "http://zero-timeout.local:8080", - account: "+14259798283", - timeoutMs: 0, - onEvent: vi.fn(), - }); - - expect(mockNativeCheck).toHaveBeenCalledWith("http://zero-timeout.local:8080", 10000); - expect(mockContainerCheck).toHaveBeenCalledWith( - "http://zero-timeout.local:8080", - 10000, - "+14259798283", - ); - expectSingleObjectCall(mockNativeStreamEvents, { - timeoutMs: 0, - }); - }); - - it("forwards timeout to container event stream", async () => { - setApiMode("container"); - mockStreamContainerEvents.mockResolvedValue(undefined); - - await streamSignalEvents({ - baseUrl: "http://localhost:8080", - timeoutMs: 45000, - onEvent: vi.fn(), - }); - - expectSingleObjectCall(mockStreamContainerEvents, { - timeoutMs: 45000, - }); - }); - - it("revalidates an unvalidated cached container mode before streaming", async () => { - setApiMode("auto"); - mockNativeCheck.mockResolvedValue({ ok: false, status: 404 }); - mockContainerCheck - .mockResolvedValueOnce({ ok: true, status: 200 }) - .mockResolvedValueOnce({ ok: true, status: 200 }) - .mockResolvedValueOnce({ - ok: false, - status: 200, - error: "Signal container receive endpoint did not upgrade to WebSocket (HTTP 200)", - }); - - await expect(signalCheck("http://auto-cache.local:8080")).resolves.toEqual({ - ok: true, - status: 200, - }); - - await expect( - streamSignalEvents({ - baseUrl: "http://auto-cache.local:8080", - account: "+14259798283", - onEvent: vi.fn(), - }), - ).rejects.toThrow("Signal API not reachable at http://auto-cache.local:8080"); - expect(mockStreamContainerEvents).not.toHaveBeenCalled(); - expect(mockContainerCheck).toHaveBeenLastCalledWith( - "http://auto-cache.local:8080", - 10000, - "+14259798283", - ); - }); - - it("does not reuse a cached container mode for no-account receive streams", async () => { - setApiMode("auto"); - mockNativeCheck.mockResolvedValue({ ok: false, status: 404 }); - mockContainerCheck.mockResolvedValue({ ok: true, status: 200 }); - - await expect(signalCheck("http://auto-cache-no-account.local:8080")).resolves.toEqual({ - ok: true, - status: 200, - }); - - await expect( - streamSignalEvents({ - baseUrl: "http://auto-cache-no-account.local:8080", - onEvent: vi.fn(), - }), - ).rejects.toThrow("Signal API not reachable at http://auto-cache-no-account.local:8080"); - expect(mockStreamContainerEvents).not.toHaveBeenCalled(); - expect(mockContainerCheck).toHaveBeenCalledTimes(2); + expect(nativeStream).not.toHaveBeenCalled(); }); }); diff --git a/extensions/signal/src/client-adapter.ts b/extensions/signal/src/client-adapter.ts index 05e92f0b173f..eb98387a73cf 100644 --- a/extensions/signal/src/client-adapter.ts +++ b/extensions/signal/src/client-adapter.ts @@ -1,15 +1,12 @@ /** * Signal client adapter - unified interface for both native signal-cli and bbernhard container. * - * This adapter provides a single API that routes to the appropriate implementation - * based on the configured API mode. Exports mirror client.ts names so consumers + * This adapter provides a single API that routes to the concrete account transport. + * Exports mirror client.ts names so consumers * only need to change their import path. */ -import { - asDateTimestampMs, - resolveExpiresAtMsFromDurationMs, -} from "openclaw/plugin-sdk/number-runtime"; +import type { SignalTransportConfig } from "./account-types.js"; import { containerCheck, containerRpcRequest, streamContainerEvents } from "./client-container.js"; import type { SignalRpcOptions } from "./client.js"; import { @@ -19,147 +16,20 @@ import { } from "./client.js"; const DEFAULT_TIMEOUT_MS = 10_000; -const MODE_CACHE_TTL_MS = 30_000; -const NATIVE_PREFERENCE_GRACE_MS = 50; export type SignalSseEvent = { event?: string; data?: string; }; -export type SignalApiMode = "native" | "container" | "auto"; - -// Cache auto-detected modes per baseUrl to avoid repeated network probes. -const detectedModeCache = new Map< - string, - { mode: "native" | "container"; expiresAt: number; receiveAccount?: string } ->(); - -function resolveConfiguredApiMode(configured?: SignalApiMode): SignalApiMode { - if (configured === "native" || configured === "container") { - return configured; - } - return "auto"; -} +export type SignalTransportKind = SignalTransportConfig["kind"]; function formatErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -function resolveAutoProbeTimeoutMs(timeoutMs: number | undefined): number { - return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0 - ? timeoutMs - : DEFAULT_TIMEOUT_MS; -} - -function waitForNativePreferenceGrace( - nativeResultPromise: Promise<{ ok: boolean }>, -): Promise<{ ok: boolean }> { - return new Promise((resolve) => { - const timer = setTimeout(() => resolve({ ok: false }), NATIVE_PREFERENCE_GRACE_MS); - timer.unref?.(); - void nativeResultPromise.then((result) => { - clearTimeout(timer); - resolve(result); - }); - }); -} - -async function resolveAutoApiMode( - baseUrl: string, - timeoutMs = DEFAULT_TIMEOUT_MS, - options: { account?: string; requireContainerReceive?: boolean } = {}, -): Promise<"native" | "container"> { - const rawNow = Date.now(); - const now = asDateTimestampMs(rawNow); - const cached = detectedModeCache.get(baseUrl); - if (cached) { - if (now !== undefined && cached.expiresAt > now) { - if ( - cached.mode !== "container" || - !options.requireContainerReceive || - (Boolean(options.account?.trim()) && cached.receiveAccount === options.account?.trim()) - ) { - return cached.mode; - } - } else { - detectedModeCache.delete(baseUrl); - } - } - const detected = await detectSignalApiMode(baseUrl, timeoutMs, options); - const expiresAt = resolveExpiresAtMsFromDurationMs(MODE_CACHE_TTL_MS, { nowMs: rawNow }); - if (expiresAt !== undefined) { - detectedModeCache.set(baseUrl, { - mode: detected, - expiresAt, - ...(detected === "container" && options.requireContainerReceive && options.account - ? { receiveAccount: options.account } - : {}), - }); - } - return detected; -} - -async function resolveApiModeForOperation(params: { - baseUrl: string; - accountId?: string; - account?: string; - requireContainerReceive?: boolean; - timeoutMs?: number; - apiMode?: SignalApiMode; -}): Promise<"native" | "container"> { - const configured = resolveConfiguredApiMode(params.apiMode); - - if (configured === "native" || configured === "container") { - return configured; - } - - return resolveAutoApiMode(params.baseUrl, params.timeoutMs ?? DEFAULT_TIMEOUT_MS, { - account: params.account, - requireContainerReceive: params.requireContainerReceive, - }); -} - -/** - * Detect which Signal API mode is available by probing endpoints. - * Native wins when both APIs are healthy because it preserves the richer JSON-RPC contract. - */ -async function detectSignalApiMode( - baseUrl: string, - timeoutMs = DEFAULT_TIMEOUT_MS, - options: { account?: string; requireContainerReceive?: boolean } = {}, -): Promise<"native" | "container"> { - const containerAccount = options.requireContainerReceive ? options.account?.trim() : undefined; - const nativeResultPromise = nativeCheck(baseUrl, timeoutMs).catch(() => ({ ok: false })); - const containerResultPromise = containerAccount - ? containerCheck(baseUrl, timeoutMs, containerAccount).catch(() => ({ ok: false })) - : options.requireContainerReceive - ? Promise.resolve({ ok: false }) - : containerCheck(baseUrl, timeoutMs).catch(() => ({ ok: false })); - - const nativeHealthyPromise = nativeResultPromise.then((result) => { - if (result.ok) { - return "native" as const; - } - throw new Error("native not ok"); - }); - const containerHealthyPromise = containerResultPromise.then((result) => { - if (result.ok) { - return "container" as const; - } - throw new Error("container not ok"); - }); - - try { - const firstHealthy = await Promise.any([nativeHealthyPromise, containerHealthyPromise]); - if (firstHealthy === "native") { - return "native"; - } - const nativeResult = await waitForNativePreferenceGrace(nativeResultPromise); - return nativeResult.ok ? "native" : "container"; - } catch { - throw new Error(`Signal API not reachable at ${baseUrl}`); - } +function usesContainer(kind: SignalTransportKind | undefined): boolean { + return kind === "container"; } /** @@ -171,21 +41,13 @@ export async function signalRpcRequest( params: Record | undefined, opts: SignalRpcOptions & { accountId?: string; - apiMode?: SignalApiMode; + transportKind?: SignalTransportKind; maxAttachmentBytes?: number; }, ): Promise { - const mode = await resolveApiModeForOperation({ - baseUrl: opts.baseUrl, - accountId: opts.accountId, - account: typeof params?.account === "string" ? params.account : undefined, - timeoutMs: opts.timeoutMs, - apiMode: opts.apiMode, - }); - if (mode === "native") { - return nativeRpcRequest(method, params, opts); - } - return containerRpcRequest(method, params, opts); + return usesContainer(opts.transportKind) + ? containerRpcRequest(method, params, opts) + : nativeRpcRequest(method, params, opts); } /** @@ -194,22 +56,15 @@ export async function signalRpcRequest( export async function signalCheck( baseUrl: string, timeoutMs = DEFAULT_TIMEOUT_MS, - options: { apiMode?: SignalApiMode } = {}, + options: { transportKind?: SignalTransportKind } = {}, ): Promise<{ ok: boolean; status?: number | null; error?: string | null }> { - const configured = resolveConfiguredApiMode(options.apiMode); - const mode = - configured === "auto" - ? await resolveAutoApiMode(baseUrl, timeoutMs).catch((error: unknown) => { - return { ok: false, status: null, error: formatErrorMessage(error) } as const; - }) - : configured; - if (typeof mode !== "string") { - return mode; + try { + return usesContainer(options.transportKind) + ? await containerCheck(baseUrl, timeoutMs) + : await nativeCheck(baseUrl, timeoutMs); + } catch (error) { + return { ok: false, status: null, error: formatErrorMessage(error) }; } - if (mode === "container") { - return containerCheck(baseUrl, timeoutMs); - } - return nativeCheck(baseUrl, timeoutMs); } /** @@ -224,18 +79,9 @@ export async function streamSignalEvents(params: { timeoutMs?: number; onEvent: (event: SignalSseEvent) => unknown; logger?: { log?: (msg: string) => void; error?: (msg: string) => void }; - apiMode?: SignalApiMode; + transportKind?: SignalTransportKind; }): Promise { - const mode = await resolveApiModeForOperation({ - baseUrl: params.baseUrl, - accountId: params.accountId, - account: params.account, - requireContainerReceive: true, - timeoutMs: resolveAutoProbeTimeoutMs(params.timeoutMs), - apiMode: params.apiMode, - }); - - if (mode === "container") { + if (usesContainer(params.transportKind)) { return streamContainerEvents({ baseUrl: params.baseUrl, account: params.account, diff --git a/extensions/signal/src/config-compat.ts b/extensions/signal/src/config-compat.ts new file mode 100644 index 000000000000..2a3e7f70311b --- /dev/null +++ b/extensions/signal/src/config-compat.ts @@ -0,0 +1,743 @@ +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-resolution"; +// Signal compatibility migration moves shipped flat transport config into account ownership. +import type { ChannelDoctorConfigMutation } from "openclaw/plugin-sdk/channel-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import type { SignalTransportConfig } from "./account-types.js"; +import { + allocateSignalManagedNativePort, + assignSignalManagedNativePort, + DEFAULT_SIGNAL_MANAGED_NATIVE_PORT, + isSignalManagedNativeConnectionUrlForBind, + isValidSignalManagedNativePort, + resolveLocalSignalTransportPort, +} from "./transport-policy.js"; +import { buildSignalTransportHttpUrl, normalizeSignalTransportUrl } from "./transport-url.js"; + +const LEGACY_TRANSPORT_FIELDS = [ + "configPath", + "httpUrl", + "httpHost", + "httpPort", + "cliPath", + "autoStart", + "startupTimeoutMs", + "receiveMode", + "ignoreStories", +] as const; + +const PENDING_LEGACY_TRANSPORT_WARNING = + "- channels.signal: legacy auto transport is ambiguous while its endpoint is unavailable; bring the endpoint online and rerun openclaw doctor --fix, or replace the retired fields with an explicit account-owned transport in openclaw.json."; +const PENDING_LEGACY_INVALID_URL_WARNING = + "- channels.signal: legacy httpUrl is invalid; keep the current config, correct httpUrl, then run openclaw doctor --fix."; +const PENDING_LEGACY_INVALID_HOST_WARNING = + "- channels.signal: legacy httpHost is invalid; keep the current config, correct httpHost, then run openclaw doctor --fix."; +const PENDING_LEGACY_INVALID_PORT_WARNING = + "- channels.signal: legacy httpPort must be an integer between 1 and 65535; correct httpPort, then run openclaw doctor --fix."; +const PENDING_LEGACY_CONTAINER_ACCOUNT_WARNING = + "- channels.signal: legacy container transport requires an account number; add channels.signal.account (or the relevant channels.signal.accounts.*.account) and rerun openclaw doctor --fix."; + +type DetectTransport = (params: { + url: string; + account?: string; +}) => Promise; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isSignalTransportConfig(value: unknown): value is SignalTransportConfig { + if (!isRecord(value)) { + return false; + } + if (value.kind === "managed-native") { + if (value.httpPort !== undefined && !isValidSignalManagedNativePort(value.httpPort)) { + return false; + } + if (value.url === undefined) { + return true; + } + if (typeof value.url !== "string") { + return false; + } + try { + normalizeSignalTransportUrl(value.url); + return true; + } catch { + return false; + } + } + if ( + (value.kind !== "external-native" && value.kind !== "container") || + typeof value.url !== "string" + ) { + return false; + } + try { + normalizeSignalTransportUrl(value.url); + return true; + } catch { + return false; + } +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function inherited(entry: Record, parent: Record, key: string) { + return Object.hasOwn(entry, key) ? entry[key] : parent[key]; +} + +function legacyBaseUrl(entry: Record, parent: Record): string { + const url = optionalString(inherited(entry, parent, "httpUrl")); + if (url) { + return normalizeSignalTransportUrl(url); + } + const host = optionalString(inherited(entry, parent, "httpHost")) ?? "127.0.0.1"; + const rawPort = inherited(entry, parent, "httpPort"); + const port = typeof rawPort === "number" ? rawPort : 8080; + return buildSignalTransportHttpUrl(host, port); +} + +function hasLegacyFields(entry: Record): boolean { + return LEGACY_TRANSPORT_FIELDS.some((field) => Object.hasOwn(entry, field)); +} + +function wasLegacySignalAccountConfigured( + entry: Record, + parent: Record, +): boolean { + return Boolean( + optionalString(inherited(entry, parent, "account")) || + optionalString(inherited(entry, parent, "configPath")) || + optionalString(inherited(entry, parent, "httpUrl")) || + optionalString(inherited(entry, parent, "httpHost")) || + optionalString(inherited(entry, parent, "cliPath")) || + typeof inherited(entry, parent, "httpPort") === "number" || + typeof inherited(entry, parent, "autoStart") === "boolean", + ); +} + +function hasInvalidLegacyHttpUrl( + entries: Record[], + parent: Record, +): boolean { + return entries.some((entry) => { + const httpUrl = optionalString(inherited(entry, parent, "httpUrl")); + if (!httpUrl) { + return false; + } + try { + normalizeSignalTransportUrl(httpUrl); + return false; + } catch { + return true; + } + }); +} + +function findInvalidLegacyDerivedEndpoint( + entries: Record[], + parent: Record, +): "host" | "port" | undefined { + for (const entry of entries) { + if (optionalString(inherited(entry, parent, "httpUrl"))) { + continue; + } + const rawPort = inherited(entry, parent, "httpPort"); + if (rawPort !== undefined && !isValidSignalManagedNativePort(rawPort)) { + return "port"; + } + const host = optionalString(inherited(entry, parent, "httpHost")) ?? "127.0.0.1"; + try { + buildSignalTransportHttpUrl(host, typeof rawPort === "number" ? rawPort : 8080); + } catch { + return "host"; + } + } + return undefined; +} + +function hasInvalidManagedTransportPort( + transports: Array, +): boolean { + return transports.some( + (transport) => + transport?.kind === "managed-native" && + transport.httpPort !== undefined && + !isValidSignalManagedNativePort(transport.httpPort), + ); +} + +function requiresDetection( + entry: Record, + parent: Record, + apiMode: unknown, +): boolean { + if (apiMode !== undefined && apiMode !== "auto") { + return false; + } + return ( + Boolean(optionalString(inherited(entry, parent, "httpUrl"))) || + !resolveLegacyAutoStart(entry, parent) + ); +} + +function resolveLegacyAutoStart( + entry: Record, + parent: Record, +): boolean { + const autoStart = inherited(entry, parent, "autoStart"); + if (typeof autoStart === "boolean") { + return autoStart; + } + return !optionalString(inherited(entry, parent, "httpUrl")); +} + +function resolveManagedConnectionUrl( + entry: Record, + parent: Record, +): string | undefined { + const httpUrl = optionalString(inherited(entry, parent, "httpUrl")); + if (!httpUrl) { + return undefined; + } + const normalizedUrl = normalizeSignalTransportUrl(httpUrl); + const endpoint = new URL(normalizedUrl); + const bindHost = (optionalString(inherited(entry, parent, "httpHost")) ?? "127.0.0.1") + .replace(/^\[|\]$/g, "") + .toLowerCase(); + const rawBindPort = inherited(entry, parent, "httpPort"); + const bindPort = typeof rawBindPort === "number" ? rawBindPort : 8080; + const endpointHost = endpoint.hostname.replace(/^\[|\]$/g, "").toLowerCase(); + const endpointPort = endpoint.port + ? Number.parseInt(endpoint.port, 10) + : endpoint.protocol === "https:" + ? 443 + : 80; + const matchesBindEndpoint = + endpoint.protocol === "http:" && endpointHost === bindHost && endpointPort === bindPort; + return matchesBindEndpoint ? undefined : normalizedUrl; +} + +function buildManagedNativeTransport( + entry: Record, + parent: Record, +): SignalTransportConfig { + const value = (key: string) => inherited(entry, parent, key); + const configPath = optionalString(value("configPath")); + const cliPath = optionalString(value("cliPath")); + const url = resolveManagedConnectionUrl(entry, parent); + const httpHost = optionalString(value("httpHost")); + const httpPort = value("httpPort"); + const startupTimeoutMs = value("startupTimeoutMs"); + const receiveMode = value("receiveMode"); + const ignoreStories = value("ignoreStories"); + return { + kind: "managed-native", + ...(configPath ? { configPath } : {}), + ...(cliPath ? { cliPath } : {}), + ...(url ? { url } : {}), + ...(httpHost ? { httpHost } : {}), + ...(typeof httpPort === "number" ? { httpPort } : {}), + ...(typeof startupTimeoutMs === "number" ? { startupTimeoutMs } : {}), + ...(receiveMode === "on-start" || receiveMode === "manual" ? { receiveMode } : {}), + ...(typeof ignoreStories === "boolean" ? { ignoreStories } : {}), + }; +} + +function resolveLegacyTransportWithoutDetection(params: { + entry: Record; + parent: Record; + apiMode: unknown; +}): SignalTransportConfig | undefined { + if (isSignalTransportConfig(params.entry.transport)) { + return params.entry.transport; + } + const baseUrl = legacyBaseUrl(params.entry, params.parent); + const autoStart = inherited(params.entry, params.parent, "autoStart"); + if (params.apiMode === "container") { + return { kind: "container", url: baseUrl }; + } + if (params.apiMode === "native") { + return resolveLegacyAutoStart(params.entry, params.parent) + ? buildManagedNativeTransport(params.entry, params.parent) + : { kind: "external-native", url: baseUrl }; + } + if (requiresDetection(params.entry, params.parent, params.apiMode)) { + return undefined; + } + if (autoStart === false) { + return { kind: "external-native", url: baseUrl }; + } + return buildManagedNativeTransport(params.entry, params.parent); +} + +async function resolveLegacyTransport(params: { + entry: Record; + parent: Record; + apiMode: unknown; + detect?: DetectTransport; +}): Promise { + const resolved = resolveLegacyTransportWithoutDetection(params); + if (resolved) { + return resolved; + } + const account = optionalString(inherited(params.entry, params.parent, "account")); + try { + const detected = await params.detect?.({ + url: legacyBaseUrl(params.entry, params.parent), + ...(account ? { account } : {}), + }); + if ( + detected?.kind === "external-native" && + resolveLegacyAutoStart(params.entry, params.parent) + ) { + return buildManagedNativeTransport(params.entry, params.parent); + } + return detected; + } catch { + if (resolveLegacyAutoStart(params.entry, params.parent)) { + // A gateway-owned daemon is normally offline while update has the service stopped. + // Preserve that explicit ownership instead of blocking migration on a live probe. + return buildManagedNativeTransport(params.entry, params.parent); + } + // Shipped auto mode could select either protocol at the same URL. Do not guess while the + // endpoint is down; the warning points to an explicit account-owned config replacement. + return undefined; + } +} + +function clearLegacyTransportFields(entry: Record): void { + for (const field of LEGACY_TRANSPORT_FIELDS) { + delete entry[field]; + } +} + +function hasRootSignalAccount(entries: Record[]): boolean { + const root = entries[0]; + return ( + entries.length === 1 || + Boolean(optionalString(root?.account)) || + isSignalTransportConfig(root?.transport) + ); +} + +function signalAccountIds(entries: Record[]): string[] { + const accounts = isRecord(entries[0]?.accounts) ? entries[0].accounts : {}; + return Object.entries(accounts) + .filter(([, entry]) => isRecord(entry)) + .map(([accountId]) => accountId); +} + +function isDefaultSignalAccountId(accountId: string | undefined): boolean { + return Boolean(accountId?.trim()) && normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID; +} + +function resolveSignalAccountKey( + accounts: Record, + accountId: string, +): string | undefined { + const normalizedAccountId = normalizeAccountId(accountId); + return Object.keys(accounts).find( + (key) => Boolean(key.trim()) && normalizeAccountId(key) === normalizedAccountId, + ); +} + +function nestedDefaultOwnsEffectiveTransport(entries: Record[]): boolean { + const accounts = isRecord(entries[0]?.accounts) ? entries[0].accounts : {}; + const nestedDefaultKey = resolveSignalAccountKey(accounts, DEFAULT_ACCOUNT_ID); + const nestedDefault = nestedDefaultKey ? accounts[nestedDefaultKey] : undefined; + return ( + isRecord(nestedDefault) && + (isSignalTransportConfig(nestedDefault.transport) || hasLegacyFields(nestedDefault)) + ); +} + +function isDiscardedTransportEntry(entries: Record[], index: number): boolean { + if (index === 0) { + // Shipped account merging let accounts.default override root transport fields. Materialize + // that effective merged entry once; probing the shadowed root can block an otherwise valid + // migration when its retired endpoint is offline. + return !hasRootSignalAccount(entries) || nestedDefaultOwnsEffectiveTransport(entries); + } + // A canonical root transport owns the default account; a nested default's + // retired endpoint fields are cleanup-only and must not block migration. + return ( + isDefaultSignalAccountId(signalAccountIds(entries)[index - 1]) && + isSignalTransportConfig(entries[0]?.transport) + ); +} + +function shouldMaterializeTransport(entries: Record[], index: number): boolean { + if (isDiscardedTransportEntry(entries, index)) { + return false; + } + const entry = entries[index]; + const parent = entries[0]; + return Boolean( + entry && + parent && + (isSignalTransportConfig(entry.transport) || wasLegacySignalAccountConfigured(entry, parent)), + ); +} + +export function clearLegacySignalTransportFieldsForAccount(params: { + cfg: OpenClawConfig; + accountId: string; +}): OpenClawConfig { + const next = structuredClone(params.cfg); + const signal = next.channels?.signal as unknown; + if (!isRecord(signal)) { + return next; + } + if (isDefaultSignalAccountId(params.accountId)) { + clearLegacyTransportFields(signal); + delete signal.apiMode; + const accounts = isRecord(signal.accounts) ? signal.accounts : undefined; + const nestedDefaultKey = accounts + ? resolveSignalAccountKey(accounts, DEFAULT_ACCOUNT_ID) + : undefined; + const nestedDefault = nestedDefaultKey ? accounts?.[nestedDefaultKey] : undefined; + if (isRecord(nestedDefault)) { + // Setup writes the implicit default transport at the channel root. + // Remove a nested copy so it cannot shadow the canonical write. + clearLegacyTransportFields(nestedDefault); + delete nestedDefault.transport; + } + return next; + } + const accounts = isRecord(signal.accounts) ? signal.accounts : undefined; + const accountKey = accounts ? resolveSignalAccountKey(accounts, params.accountId) : undefined; + const account = accountKey ? accounts?.[accountKey] : undefined; + if (isRecord(account)) { + clearLegacyTransportFields(account); + } + return next; +} + +function allocateMigratedManagedPorts(params: { + entries: Record[]; + transports: Array; +}): Array { + const reservedPorts = new Set(); + const rootIsAccount = hasRootSignalAccount(params.entries); + const accountIds = signalAccountIds(params.entries); + const nestedDefaultOffset = accountIds.findIndex((accountId) => + isDefaultSignalAccountId(accountId), + ); + const canonicalDefaultIndex = isSignalTransportConfig(params.entries[0]?.transport) + ? 0 + : nestedDefaultOffset >= 0 + ? nestedDefaultOffset + 1 + : rootIsAccount + ? 0 + : undefined; + for (const [index, transport] of params.transports.entries()) { + if (!transport || (index === 0 && canonicalDefaultIndex !== 0)) { + continue; + } + if (transport.kind !== "managed-native") { + const localPort = resolveLocalSignalTransportPort(transport.url); + if (localPort !== undefined) { + reservedPorts.add(localPort); + } + continue; + } + if (transport.url && !isSignalManagedNativeConnectionUrlForBind(transport)) { + const localConnectionPort = resolveLocalSignalTransportPort(transport.url); + if (localConnectionPort !== undefined) { + reservedPorts.add(localConnectionPort); + } + } + if (index === canonicalDefaultIndex || isRecord(params.entries[index]?.transport)) { + reservedPorts.add(transport.httpPort ?? DEFAULT_SIGNAL_MANAGED_NATIVE_PORT); + } + } + return params.transports.map((transport, index) => { + if (!transport || (index === 0 && canonicalDefaultIndex !== 0)) { + return transport; + } + if (transport.kind !== "managed-native") { + return transport; + } + const existingCanonical = isRecord(params.entries[index]?.transport); + if (existingCanonical || index === canonicalDefaultIndex) { + return transport; + } + const rawPreferredPort = params.entries[index]?.httpPort; + const preferredPort = + typeof rawPreferredPort === "number" ? rawPreferredPort : transport.httpPort; + const httpPort = allocateSignalManagedNativePort({ + reservedPorts, + ...(typeof preferredPort === "number" ? { preferredPort } : {}), + }); + reservedPorts.add(httpPort); + return assignSignalManagedNativePort(transport, httpPort); + }); +} + +function applyMigratedSignalTransports(params: { + cfg: OpenClawConfig; + entries: Record[]; + transports: Array; +}): OpenClawConfig | undefined { + const next = structuredClone(params.cfg); + const nextSignal = next.channels?.signal as unknown; + if (!isRecord(nextSignal)) { + return undefined; + } + const accountIds = signalAccountIds(params.entries); + const nextAccounts = isRecord(nextSignal.accounts) ? nextSignal.accounts : {}; + const nextEntries = [nextSignal, ...Object.values(nextAccounts).filter(isRecord)]; + const rootIsAccount = hasRootSignalAccount(params.entries); + const canonicalRootTransport = isSignalTransportConfig(params.entries[0]?.transport) + ? params.entries[0].transport + : undefined; + for (const [index, entry] of nextEntries.entries()) { + const accountId = index === 0 ? undefined : accountIds[index - 1]; + if (isDefaultSignalAccountId(accountId)) { + const defaultTransport = canonicalRootTransport ?? params.transports[index]; + if (defaultTransport) { + nextSignal.transport = defaultTransport; + } else { + delete nextSignal.transport; + } + delete entry.transport; + } else if (index === 0 && !rootIsAccount) { + delete entry.transport; + } else if (params.transports[index]) { + entry.transport = params.transports[index]; + } else { + delete entry.transport; + } + clearLegacyTransportFields(entry); + } + delete nextSignal.apiMode; + return next; +} + +function hasContainerTransportWithoutEffectiveAccount(cfg: OpenClawConfig): boolean { + const signal = cfg.channels?.signal as unknown; + if (!isRecord(signal)) { + return false; + } + const accounts = isRecord(signal.accounts) ? signal.accounts : {}; + const rootTransport = isSignalTransportConfig(signal.transport) ? signal.transport : undefined; + const defaultKey = resolveSignalAccountKey(accounts, DEFAULT_ACCOUNT_ID); + const defaultEntry = defaultKey ? accounts[defaultKey] : undefined; + const defaultAccount = + isRecord(defaultEntry) && defaultEntry.account !== undefined + ? optionalString(defaultEntry.account) + : optionalString(signal.account); + const channelEnabled = signal.enabled !== false; + const defaultEnabled = !isRecord(defaultEntry) || defaultEntry.enabled !== false; + if (rootTransport?.kind === "container" && channelEnabled && defaultEnabled && !defaultAccount) { + return true; + } + for (const [accountId, entry] of Object.entries(accounts)) { + if (!isRecord(entry)) { + continue; + } + if (!channelEnabled || entry.enabled === false) { + continue; + } + const isDefaultAccount = isDefaultSignalAccountId(accountId); + const transport = + isDefaultAccount && rootTransport + ? rootTransport + : isSignalTransportConfig(entry.transport) + ? entry.transport + : undefined; + if (transport?.kind !== "container" || (isDefaultAccount && rootTransport)) { + continue; + } + const account = + entry.account === undefined ? optionalString(signal.account) : optionalString(entry.account); + if (!account) { + return true; + } + } + return false; +} + +export async function migrateLegacySignalTransportConfig(params: { + cfg: OpenClawConfig; + detect?: DetectTransport; +}): Promise { + const signal = params.cfg.channels?.signal as unknown; + if (!isRecord(signal)) { + return { config: params.cfg, changes: [] }; + } + const accounts = isRecord(signal.accounts) ? signal.accounts : {}; + const hasLegacy = + Object.hasOwn(signal, "apiMode") || + hasLegacyFields(signal) || + Object.values(accounts).some((entry) => isRecord(entry) && hasLegacyFields(entry)); + if (!hasLegacy) { + return { config: params.cfg, changes: [] }; + } + const apiMode = signal.apiMode; + const entries = [signal, ...Object.values(accounts).filter(isRecord)]; + const migrationEntries = entries.filter((_, index) => shouldMaterializeTransport(entries, index)); + const legacyResolutionEntries = migrationEntries.filter( + (entry) => !isSignalTransportConfig(entry.transport), + ); + const invalidDerivedEndpoint = findInvalidLegacyDerivedEndpoint(legacyResolutionEntries, signal); + if (invalidDerivedEndpoint) { + return { + config: params.cfg, + changes: [], + warnings: [ + invalidDerivedEndpoint === "port" + ? PENDING_LEGACY_INVALID_PORT_WARNING + : PENDING_LEGACY_INVALID_HOST_WARNING, + ], + }; + } + if (hasInvalidLegacyHttpUrl(legacyResolutionEntries, signal)) { + return { + config: params.cfg, + changes: [], + warnings: [PENDING_LEGACY_INVALID_URL_WARNING], + }; + } + if ( + !params.detect && + legacyResolutionEntries.some((entry) => requiresDetection(entry, signal, apiMode)) + ) { + return { + config: params.cfg, + changes: [], + warnings: [PENDING_LEGACY_TRANSPORT_WARNING], + }; + } + const resolvedTransports = await Promise.all( + entries.map(async (entry, index) => + !shouldMaterializeTransport(entries, index) + ? undefined + : await resolveLegacyTransport({ entry, parent: signal, apiMode, detect: params.detect }), + ), + ); + if (hasInvalidManagedTransportPort(resolvedTransports)) { + return { + config: params.cfg, + changes: [], + warnings: [PENDING_LEGACY_INVALID_PORT_WARNING], + }; + } + const transports = allocateMigratedManagedPorts({ + entries, + transports: resolvedTransports, + }); + if ( + transports.some((transport, index) => shouldMaterializeTransport(entries, index) && !transport) + ) { + return { + config: params.cfg, + changes: [], + warnings: [PENDING_LEGACY_TRANSPORT_WARNING], + }; + } + const next = applyMigratedSignalTransports({ cfg: params.cfg, entries, transports }); + if (!next) { + return { config: params.cfg, changes: [] }; + } + if (hasContainerTransportWithoutEffectiveAccount(next)) { + return { + config: params.cfg, + changes: [], + warnings: [PENDING_LEGACY_CONTAINER_ACCOUNT_WARNING], + }; + } + return { + config: next, + changes: [ + "Migrated channels.signal transport settings to concrete account-owned transport objects.", + ], + }; +} + +export function migrateLegacySignalTransportConfigSync( + cfg: OpenClawConfig, +): ChannelDoctorConfigMutation { + const signal = cfg.channels?.signal as unknown; + if (!isRecord(signal)) { + return { config: cfg, changes: [] }; + } + const accounts = isRecord(signal.accounts) ? signal.accounts : {}; + const hasLegacy = + Object.hasOwn(signal, "apiMode") || + hasLegacyFields(signal) || + Object.values(accounts).some((entry) => isRecord(entry) && hasLegacyFields(entry)); + if (!hasLegacy) { + return { config: cfg, changes: [] }; + } + const entries = [signal, ...Object.values(accounts).filter(isRecord)]; + const migrationEntries = entries.filter((_, index) => shouldMaterializeTransport(entries, index)); + const legacyResolutionEntries = migrationEntries.filter( + (entry) => !isSignalTransportConfig(entry.transport), + ); + const invalidDerivedEndpoint = findInvalidLegacyDerivedEndpoint(legacyResolutionEntries, signal); + if (invalidDerivedEndpoint) { + return { + config: cfg, + changes: [], + warnings: [ + invalidDerivedEndpoint === "port" + ? PENDING_LEGACY_INVALID_PORT_WARNING + : PENDING_LEGACY_INVALID_HOST_WARNING, + ], + }; + } + if (hasInvalidLegacyHttpUrl(legacyResolutionEntries, signal)) { + return { + config: cfg, + changes: [], + warnings: [PENDING_LEGACY_INVALID_URL_WARNING], + }; + } + const resolvedTransports = entries.map((entry, index) => { + if (!shouldMaterializeTransport(entries, index)) { + return undefined; + } + return resolveLegacyTransportWithoutDetection({ + entry, + parent: signal, + apiMode: signal.apiMode, + }); + }); + if (hasInvalidManagedTransportPort(resolvedTransports)) { + return { + config: cfg, + changes: [], + warnings: [PENDING_LEGACY_INVALID_PORT_WARNING], + }; + } + const transports = allocateMigratedManagedPorts({ + entries, + transports: resolvedTransports, + }); + if ( + transports.some((transport, index) => shouldMaterializeTransport(entries, index) && !transport) + ) { + return { config: cfg, changes: [], warnings: [PENDING_LEGACY_TRANSPORT_WARNING] }; + } + const next = applyMigratedSignalTransports({ cfg, entries, transports }); + if (!next) { + return { config: cfg, changes: [] }; + } + if (hasContainerTransportWithoutEffectiveAccount(next)) { + return { + config: cfg, + changes: [], + warnings: [PENDING_LEGACY_CONTAINER_ACCOUNT_WARNING], + }; + } + return { + config: next, + changes: [ + "Migrated channels.signal transport settings to concrete account-owned transport objects.", + ], + }; +} diff --git a/extensions/signal/src/config-schema.test.ts b/extensions/signal/src/config-schema.test.ts index 075652849405..cb5e1f2fa579 100644 --- a/extensions/signal/src/config-schema.test.ts +++ b/extensions/signal/src/config-schema.test.ts @@ -1,5 +1,5 @@ // Signal tests cover config schema plugin behavior. -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { SignalConfigSchema } from "../config-api.js"; function expectValidSignalConfig(config: unknown) { @@ -120,22 +120,135 @@ describe("signal groups schema", () => { }); }); - it("accepts channel apiMode", () => { - for (const apiMode of ["auto", "native", "container"]) { - expectValidSignalConfig({ apiMode }); - } + it("accepts account-owned transport configurations", () => { + expectValidSignalConfig({ + account: "+15555550123", + transport: { + kind: "managed-native", + cliPath: "/opt/signal-cli", + url: "http://127.0.0.1:8181", + httpHost: "127.0.0.1", + httpPort: 8181, + }, + accounts: { + native: { + transport: { + kind: "external-native", + url: "http://signal-native:8080", + }, + }, + container: { + transport: { + kind: "container", + url: "http://signal-container:8080", + }, + }, + }, + }); }); - it("rejects per-account apiMode", () => { + it("rejects a root container transport without an effective account", () => { + const issues = expectInvalidSignalConfig({ + transport: { + kind: "container", + url: "http://signal-container:8080", + }, + }); + + expect(issues.map((issue) => issue.path.join("."))).toContain("account"); + }); + + it("rejects a named container transport without an inherited or owned account", () => { const issues = expectInvalidSignalConfig({ accounts: { - primary: { - apiMode: "container", + work: { + transport: { + kind: "container", + url: "http://signal-container:8080", + }, }, }, }); - expect(issues.map((issue) => issue.path.join("."))).toContain("accounts.primary"); + expect(issues.map((issue) => issue.path.join("."))).toContain("accounts.work.account"); + }); + + it("allows disabled account-less container transports", () => { + expectValidSignalConfig({ + enabled: false, + transport: { + kind: "container", + url: "http://signal-container:8080", + }, + }); + expectValidSignalConfig({ + accounts: { + work: { + enabled: false, + transport: { + kind: "container", + url: "http://signal-container:8080", + }, + }, + }, + }); + }); + + it("accepts a default-account number stored beside a root container transport", () => { + expectValidSignalConfig({ + transport: { + kind: "container", + url: "http://signal-container:8080", + }, + accounts: { + Default: { + account: "+15555550123", + }, + }, + }); + }); + + it("rejects managed transport ports outside the TCP range", () => { + expectInvalidSignalConfig({ + transport: { + kind: "managed-native", + httpPort: 65_536, + }, + }); + }); + + it("rejects the retired apiMode shape", () => { + const issues = expectInvalidSignalConfig({ apiMode: "container" }); + + expect(issues.map((issue) => issue.path.join("."))).toContain(""); + }); + + it("rejects transport fields that belong to another kind", () => { + expectInvalidSignalConfig({ + transport: { + kind: "container", + url: "http://signal-container:8080", + cliPath: "/opt/signal-cli", + }, + }); + }); + + it("rejects non-HTTP transport URLs", () => { + expectInvalidSignalConfig({ + transport: { + kind: "external-native", + url: "ftp://signal-native:8080", + }, + }); + }); + + it("rejects transport URLs containing credentials", () => { + expectInvalidSignalConfig({ + transport: { + kind: "container", + url: "http://user@signal-container:8080", + }, + }); }); it("accepts top-level group overrides", () => { @@ -178,3 +291,36 @@ describe("signal groups schema", () => { expect(issues.map((issue) => issue.path.join("."))).toEqual(["groups.*"]); }); }); + +describe("Signal post-core update schema", () => { + const legacyConfig = { + account: "+15555550123", + apiMode: "container", + httpUrl: "http://signal-container:8080", + autoStart: false, + }; + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("accepts shipped transport fields only while update finalization owns migration", () => { + vi.stubEnv("OPENCLAW_UPDATE_IN_PROGRESS", "1"); + + expect(SignalConfigSchema.safeParse(legacyConfig).success).toBe(true); + }); + + it("keeps normal runtime validation canonical", () => { + vi.stubEnv("OPENCLAW_UPDATE_IN_PROGRESS", "0"); + + expect(SignalConfigSchema.safeParse(legacyConfig).success).toBe(false); + }); + + it("closes the temporary schema window without reloading modules", () => { + vi.stubEnv("OPENCLAW_UPDATE_IN_PROGRESS", "1"); + expect(SignalConfigSchema.safeParse(legacyConfig).success).toBe(true); + + vi.stubEnv("OPENCLAW_UPDATE_IN_PROGRESS", "0"); + expect(SignalConfigSchema.safeParse(legacyConfig).success).toBe(false); + }); +}); diff --git a/extensions/signal/src/config-schema.ts b/extensions/signal/src/config-schema.ts index 7f11bc3bdd17..0461540311cf 100644 --- a/extensions/signal/src/config-schema.ts +++ b/extensions/signal/src/config-schema.ts @@ -1,4 +1,9 @@ // Signal helper module supports config schema behavior. +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + resolveAccountEntry, +} from "openclaw/plugin-sdk/account-resolution"; import { buildChannelConfigSchema, buildChannelReactionShape, @@ -11,9 +16,91 @@ import { requireAllowlistAllowFrom, requireOpenAllowFrom, } from "openclaw/plugin-sdk/channel-config-schema"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { z } from "zod"; import { signalChannelConfigUiHints } from "./config-ui-hints.js"; +const SIGNAL_RETIRED_TRANSPORT_KEYS = [ + "apiMode", + "configPath", + "httpUrl", + "httpHost", + "httpPort", + "cliPath", + "autoStart", + "startupTimeoutMs", + "receiveMode", + "ignoreStories", +] as const; + +const SIGNAL_TRANSPORT_URL_PATTERN = /^[Hh][Tt][Tt][Pp][Ss]?:\/\/(?![^/?#]*@)/; +const SignalTransportUrlSchema = z + .string() + .url() + // Keep this as a regex so the HTTP-only and credential-free contract survives JSON Schema + // generation. Runtime URL parsing remains the final canonicalization boundary. + .regex( + SIGNAL_TRANSPORT_URL_PATTERN, + "Expected http:// or https:// URL without embedded credentials", + ); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function projectSignalConfigForUpdateValidation(value: unknown): unknown { + if (process.env.OPENCLAW_UPDATE_IN_PROGRESS !== "1" || !isRecord(value)) { + return value; + } + const next = { ...value }; + for (const key of SIGNAL_RETIRED_TRANSPORT_KEYS) { + delete next[key]; + } + if (isRecord(value.accounts)) { + next.accounts = Object.fromEntries( + Object.entries(value.accounts).map(([accountId, account]) => { + if (!isRecord(account)) { + return [accountId, account]; + } + const nextAccount = { ...account }; + for (const key of SIGNAL_RETIRED_TRANSPORT_KEYS) { + delete nextAccount[key]; + } + return [accountId, nextAccount]; + }), + ); + } + return next; +} + +const SignalTransportSchema = z.discriminatedUnion("kind", [ + z + .object({ + kind: z.literal("managed-native"), + configPath: z.string().optional(), + url: SignalTransportUrlSchema.optional(), + httpHost: z.string().optional(), + httpPort: z.number().int().min(1).max(65_535).optional(), + cliPath: ExecutableTokenSchema.optional(), + startupTimeoutMs: z.number().int().min(1000).max(120000).optional(), + receiveMode: z.union([z.literal("on-start"), z.literal("manual")]).optional(), + ignoreStories: z.boolean().optional(), + }) + .strict(), + z + .object({ + kind: z.literal("external-native"), + url: SignalTransportUrlSchema, + }) + .strict(), + z + .object({ + kind: z.literal("container"), + url: SignalTransportUrlSchema, + }) + .strict(), +]); + const DirectGroupReplyToModeByChatTypeSchema = z .object({ direct: ReplyToModeSchema.optional(), @@ -40,14 +127,8 @@ const SignalAccountSchemaBase = z }), account: z.string().optional(), accountUuid: z.string().optional(), - configPath: z.string().optional(), - httpUrl: z.string().optional(), - cliPath: ExecutableTokenSchema.optional(), - autoStart: z.boolean().optional(), - startupTimeoutMs: z.number().int().min(1000).max(120000).optional(), - receiveMode: z.union([z.literal("on-start"), z.literal("manual")]).optional(), + transport: SignalTransportSchema.optional(), ignoreAttachments: z.boolean().optional(), - ignoreStories: z.boolean().optional(), sendReadReceipts: ChannelSendReadReceiptsSchema, aliases: z.record(z.string(), z.string()).optional(), groups: SignalGroupsSchema, @@ -66,13 +147,15 @@ const SignalAccountSchemaBase = z }) .strict(); -export const SignalConfigSchema = SignalAccountSchemaBase.extend({ - apiMode: z.enum(["auto", "native", "container"]).optional(), +const SignalConfigSchemaBase = SignalAccountSchemaBase.extend({ // Account-level schemas skip allowFrom validation because accounts inherit // allowFrom from the parent channel config at runtime. accounts: z.record(z.string(), SignalAccountSchemaBase.optional()).optional(), defaultAccount: z.string().optional(), -}).superRefine((value, ctx) => { +}); +type SignalConfigValidationValue = z.infer; + +function validateSignalConfigAllowFrom(value: SignalConfigValidationValue, ctx: z.RefinementCtx) { requireOpenAllowFrom({ policy: value.dmPolicy, allowFrom: value.allowFrom, @@ -112,8 +195,62 @@ export const SignalConfigSchema = SignalAccountSchemaBase.extend({ 'channels.signal.accounts.*.dmPolicy="allowlist" requires channels.signal.accounts.*.allowFrom (or channels.signal.allowFrom) to contain at least one sender ID', }); } +} + +function validateSignalContainerAccounts(value: SignalConfigValidationValue, ctx: z.RefinementCtx) { + const defaultAccount = resolveAccountEntry(value.accounts, DEFAULT_ACCOUNT_ID); + const effectiveDefaultAccount = + defaultAccount?.account === undefined ? value.account : defaultAccount.account; + const channelEnabled = value.enabled !== false; + const defaultEnabled = defaultAccount?.enabled !== false; + if ( + value.transport?.kind === "container" && + channelEnabled && + defaultEnabled && + !normalizeOptionalString(effectiveDefaultAccount) + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "channels.signal container transport requires an account number on the channel or default account", + path: ["account"], + }); + } + + for (const [accountId, account] of Object.entries(value.accounts ?? {})) { + if (!account || !channelEnabled || account.enabled === false) { + continue; + } + const isDefaultAccount = normalizeAccountId(accountId) === DEFAULT_ACCOUNT_ID; + const effectiveTransport = + isDefaultAccount && value.transport ? value.transport : account.transport; + if (effectiveTransport?.kind !== "container" || (isDefaultAccount && value.transport)) { + continue; + } + const effectiveAccount = account.account === undefined ? value.account : account.account; + if (!normalizeOptionalString(effectiveAccount)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "channels.signal account container transport requires an account number on the account or channel", + path: ["accounts", accountId, "account"], + }); + } + } +} + +const CanonicalSignalConfigSchema = SignalConfigSchemaBase.superRefine((value, ctx) => { + validateSignalConfigAllowFrom(value, ctx); + validateSignalContainerAccounts(value, ctx); }); +// During updater-owned migration, validate a projected canonical shape while doctor repairs the +// untouched source config. Normal runtime validation remains strict and reads only current keys. +export const SignalConfigSchema = z.preprocess( + projectSignalConfigForUpdateValidation, + CanonicalSignalConfigSchema, +); + export const SignalChannelConfigSchema = buildChannelConfigSchema(SignalConfigSchema, { uiHints: signalChannelConfigUiHints, }); diff --git a/extensions/signal/src/config-ui-hints.ts b/extensions/signal/src/config-ui-hints.ts index 56ee56f56011..c6aebb639c28 100644 --- a/extensions/signal/src/config-ui-hints.ts +++ b/extensions/signal/src/config-ui-hints.ts @@ -26,8 +26,20 @@ export const signalChannelConfigUiHints = { "accounts.*.defaultTo": { presentation: "phone-number" }, "accounts.*.groupAllowFrom.*": { presentation: "phone-number" }, "accounts.*.reactionAllowlist.*": { presentation: "phone-number" }, - configPath: { + transport: { + label: "Signal Transport", + help: "Account-owned native process or external endpoint configuration. Named accounts do not inherit this value.", + }, + "transport.kind": { + label: "Signal Transport Kind", + help: "Use managed-native to let OpenClaw start signal-cli, external-native for an existing native daemon, or container for signal-cli-rest-api.", + }, + "transport.configPath": { label: "Signal CLI Config Path", help: "Optional directory passed to signal-cli via --config when the service needs a non-default signal-cli data path.", }, + "transport.url": { + label: "Signal Transport URL", + help: "Base URL for an external-native or container transport, or the connection endpoint for a managed-native daemon when it differs from the bind address.", + }, } satisfies Record; diff --git a/extensions/signal/src/core.test.ts b/extensions/signal/src/core.test.ts index e3a0cb9ee5de..eece4ee04f8c 100644 --- a/extensions/signal/src/core.test.ts +++ b/extensions/signal/src/core.test.ts @@ -11,7 +11,8 @@ import { type WizardPrompter, } from "openclaw/plugin-sdk/plugin-test-runtime"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { listSignalAccountIds } from "./accounts.js"; import { clearSignalApprovalReactionTargetsForTest, resolveSignalApprovalReactionTargetWithPersistence, @@ -37,9 +38,14 @@ import { normalizeSignalAccountInput, signalDmPolicy, } from "./setup-core.js"; +import * as transportDetectionModule from "./transport-detection.js"; const getSignalSetupStatus = createPluginSetupWizardStatus(signalPlugin); +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("looksLikeUuid", () => { it("accepts hyphenated UUIDs", () => { expect(looksLikeUuid("123e4567-e89b-12d3-a456-426614174000")).toBe(true); @@ -60,17 +66,22 @@ describe("looksLikeUuid", () => { }); describe("signal sender identity", () => { - it("brackets IPv6 hosts in setup-derived HTTP URLs", () => { + it("keeps IPv6 bind hosts on the managed transport", () => { expect(buildSignalSetupPatch({ httpHost: "::1", httpPort: "9090" })).toMatchObject({ - httpUrl: "http://[::1]:9090", - autoStart: true, + transport: { + kind: "managed-native", + httpHost: "::1", + httpPort: 9090, + }, }); }); - it("disables local daemon ownership for an explicit external HTTP URL", () => { + it("uses external-native ownership for an explicit HTTP URL", () => { expect(buildSignalSetupPatch({ httpUrl: "http://signal.example:9090" })).toMatchObject({ - httpUrl: "http://signal.example:9090", - autoStart: false, + transport: { + kind: "external-native", + url: "http://signal.example:9090", + }, }); }); @@ -168,6 +179,14 @@ describe("probeSignal", () => { enabled: true, configured: true, baseUrl: "http://127.0.0.1:8080", + transport: { + kind: "managed-native", + baseUrl: "http://127.0.0.1:8080", + cliPath: "signal-cli", + httpHost: "127.0.0.1", + httpPort: 8080, + startupTimeoutMs: 30_000, + }, } as never, timeoutMs: 1000, }; @@ -211,15 +230,31 @@ describe("probeSignal", () => { expect(res.version).toBe(null); }); + it("returns auto transport detection failures as probe data", async () => { + vi.spyOn(transportDetectionModule, "detectSignalTransport").mockRejectedValueOnce( + new Error("Signal transport not reachable at http://127.0.0.1:8080"), + ); + + const res = await probeSignal("http://127.0.0.1:8080", 1000, { apiMode: "auto" }); + + expect(res).toMatchObject({ + ok: false, + status: null, + error: "Signal transport not reachable at http://127.0.0.1:8080", + version: null, + }); + expect(res.elapsedMs).toBeGreaterThanOrEqual(0); + }); + it("setup status lines use the selected account cliPath", async () => { const status = await getSignalSetupStatus({ cfg: { channels: { signal: { - cliPath: "/tmp/root-signal-cli", + transport: { kind: "managed-native", cliPath: "/tmp/root-signal-cli" }, accounts: { work: { - cliPath: "/tmp/work-signal-cli", + transport: { kind: "managed-native", cliPath: "/tmp/work-signal-cli" }, }, }, }, @@ -236,11 +271,11 @@ describe("probeSignal", () => { cfg: { channels: { signal: { - cliPath: "/tmp/root-signal-cli", + transport: { kind: "managed-native", cliPath: "/tmp/root-signal-cli" }, defaultAccount: "work", accounts: { work: { - cliPath: "/tmp/work-signal-cli", + transport: { kind: "managed-native", cliPath: "/tmp/work-signal-cli" }, }, }, }, @@ -258,16 +293,13 @@ describe("probeSignal", () => { channels: { signal: { defaultAccount: "work", - cliPath: "/tmp/root-signal-cli", + transport: { kind: "managed-native", cliPath: "/tmp/root-signal-cli" }, accounts: { alerts: { - cliPath: "/tmp/alerts-signal-cli", + transport: { kind: "managed-native", cliPath: "/tmp/alerts-signal-cli" }, }, work: { - cliPath: "", account: "", - httpHost: "", - httpUrl: "", }, }, }, @@ -1175,6 +1207,24 @@ describe("signal outbound", () => { }); describe("signal setup parsing", () => { + it("removes an accountUuid-only default account when named accounts remain", () => { + const next = signalPlugin.config?.deleteAccount?.({ + cfg: { + channels: { + signal: { + accountUuid: "123e4567-e89b-12d3-a456-426614174000", + transport: { kind: "managed-native" }, + accounts: { work: { account: "+15555550123" } }, + }, + }, + }, + accountId: "default", + }); + + expect(next?.channels?.signal?.accountUuid).toBeUndefined(); + expect(listSignalAccountIds(next ?? {})).toEqual(["work"]); + }); + it("accepts already normalized numbers", () => { expect(normalizeSignalAccountInput("+15555550123")).toBe("+15555550123"); }); diff --git a/extensions/signal/src/doctor.ts b/extensions/signal/src/doctor.ts new file mode 100644 index 000000000000..a42841005f0b --- /dev/null +++ b/extensions/signal/src/doctor.ts @@ -0,0 +1,12 @@ +// Signal doctor resolves ambiguous shipped auto-mode endpoints once and persists a concrete kind. +import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract"; +import { normalizeCompatibilityConfig } from "../doctor-contract-api.js"; +import { migrateLegacySignalTransportConfig } from "./config-compat.js"; + +export const signalDoctor: ChannelDoctorAdapter = { + normalizeCompatibilityConfig, + cleanStaleConfig: async ({ cfg }) => { + const { detectSignalTransport } = await import("./transport-detection.runtime.js"); + return await migrateLegacySignalTransportConfig({ cfg, detect: detectSignalTransport }); + }, +}; diff --git a/extensions/signal/src/monitor.tool-result.autostart.test.ts b/extensions/signal/src/monitor.tool-result.autostart.test.ts index fcb1197d5c5e..aebee336f1ae 100644 --- a/extensions/signal/src/monitor.tool-result.autostart.test.ts +++ b/extensions/signal/src/monitor.tool-result.autostart.test.ts @@ -72,6 +72,36 @@ function expectWaitForTransportReadyTimeout(timeoutMs: number) { } describe("monitorSignalProvider autostart", () => { + it.each(["external-native", "container"] as const)( + "does not spawn a daemon for %s transport", + async (kind) => { + const abortController = createAutoAbortController(); + setSignalToolResultTestConfig({ + channels: { + signal: { + transport: { kind, url: `http://${kind}:8080` }, + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }); + + await runMonitorWithMocks({ + abortSignal: abortController.signal, + runtime: createMonitorRuntime(), + }); + + expect(spawnSignalDaemonMock).not.toHaveBeenCalled(); + expect(waitForTransportReadyMock).not.toHaveBeenCalled(); + expect(streamMock).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: `http://${kind}:8080`, + transportKind: kind, + }), + ); + }, + ); + it("uses bounded readiness checks when auto-starting the daemon", async () => { const runtime = createMonitorRuntime(); setSignalAutoStartConfig(); @@ -115,7 +145,7 @@ describe("monitorSignalProvider autostart", () => { expectWaitForTransportReadyTimeout(90_000); }); - it("passes channels.signal.configPath to signal-cli daemon startup", async () => { + it("passes managed transport configPath to signal-cli daemon startup", async () => { const runtime = createMonitorRuntime(); setSignalAutoStartConfig({ configPath: "~/.openclaw/signal-cli" }); const abortController = createAutoAbortController(); @@ -134,24 +164,7 @@ describe("monitorSignalProvider autostart", () => { ); }); - it("passes a bare IPv6 bind host from an IPv6 daemon URL", async () => { - const runtime = createMonitorRuntime(); - setSignalAutoStartConfig(); - const abortController = createAutoAbortController(); - - await runMonitorWithMocks({ - autoStart: true, - baseUrl: "http://[::1]:9090", - abortSignal: abortController.signal, - runtime, - }); - - expect(spawnSignalDaemonMock).toHaveBeenCalledWith( - expect.objectContaining({ httpHost: "::1", httpPort: 9090 }), - ); - }); - - it("omits configPath when channels.signal.configPath is blank", async () => { + it("omits configPath when managed transport configPath is blank", async () => { const runtime = createMonitorRuntime(); setSignalAutoStartConfig({ configPath: " " }); const abortController = createAutoAbortController(); diff --git a/extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts b/extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts index a83e3defbcf0..b5ed5f31cd82 100644 --- a/extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts +++ b/extensions/signal/src/monitor.tool-result.pairs-uuid-only-senders-uuid-allowlist-entry.test.ts @@ -248,6 +248,15 @@ describe("monitorSignalProvider tool results", () => { const abortController = new AbortController(); const maxBytes = 2 * 1024 * 1024; const expectedMaxResponseBytes = Math.ceil((maxBytes * 4) / 3) + 64 * 1024; + setSignalToolResultTestConfig({ + channels: { + signal: { + transport: { kind: "container", url: "http://container:8080" }, + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + }); replyMock.mockResolvedValue({ text: "ok" }); signalRpcRequestMock.mockResolvedValue({ data: Buffer.from("hello").toString("base64") }); @@ -271,8 +280,6 @@ describe("monitorSignalProvider tool results", () => { }); await monitorSignalProvider({ - autoStart: false, - baseUrl: "http://127.0.0.1:8080", mediaMaxMb: 2, abortSignal: abortController.signal, }); @@ -284,9 +291,9 @@ describe("monitorSignalProvider tool results", () => { recipient: "+15550001111", }, { - baseUrl: "http://127.0.0.1:8080", + baseUrl: "http://container:8080", timeoutMs: undefined, - apiMode: "auto", + transportKind: "container", maxResponseBytes: expectedMaxResponseBytes, }, ); diff --git a/extensions/signal/src/monitor.tool-result.test-harness.ts b/extensions/signal/src/monitor.tool-result.test-harness.ts index f5466382d95e..a81792bee8ef 100644 --- a/extensions/signal/src/monitor.tool-result.test-harness.ts +++ b/extensions/signal/src/monitor.tool-result.test-harness.ts @@ -98,16 +98,42 @@ export function createSignalToolResultConfig( const base = config as { channels?: Record }; const channels = base.channels ?? {}; const signal = (channels.signal ?? {}) as Record; + const { + autoStart, + cliPath, + configPath, + httpHost, + httpPort, + startupTimeoutMs, + receiveMode, + ignoreAttachments, + ignoreStories, + ...accountOverrides + } = overrides; + const transport = + autoStart === false + ? { kind: "external-native", url: "http://127.0.0.1:8080" } + : { + kind: "managed-native", + ...(typeof cliPath === "string" ? { cliPath } : {}), + ...(typeof configPath === "string" ? { configPath } : {}), + ...(typeof httpHost === "string" ? { httpHost } : {}), + ...(typeof httpPort === "number" ? { httpPort } : {}), + ...(typeof startupTimeoutMs === "number" ? { startupTimeoutMs } : {}), + ...(receiveMode === "on-start" || receiveMode === "manual" ? { receiveMode } : {}), + ...(typeof ignoreStories === "boolean" ? { ignoreStories } : {}), + }; return { ...base, channels: { ...channels, signal: { ...signal, - autoStart: true, + transport, + ...(typeof ignoreAttachments === "boolean" ? { ignoreAttachments } : {}), dmPolicy: "open", allowFrom: ["*"], - ...overrides, + ...accountOverrides, }, }, }; @@ -299,7 +325,11 @@ export function installSignalToolResultTestHooks() { messages: { responsePrefix: "PFX" }, session: { store: signalToolResultSessionStore.path }, channels: { - signal: { autoStart: false, dmPolicy: "open", allowFrom: ["*"] }, + signal: { + transport: { kind: "external-native", url: "http://127.0.0.1:8080" }, + dmPolicy: "open", + allowFrom: ["*"], + }, }, }; diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index 835ed40ba8d9..439b22abef9a 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -46,6 +46,7 @@ import { resolveSignalAccount, resolveSignalReplyToMode } from "./accounts.js"; import { isSignalNativeApprovalHandlerConfigured } from "./approval-native.js"; import { addSignalApprovalReactionHintToStructuredPayload } from "./approval-reactions.js"; import { signalRpcRequest, signalCheck } from "./client-adapter.js"; +import type { SignalTransportKind } from "./client-adapter.js"; import { formatSignalDaemonExit, spawnSignalDaemon, type SignalDaemonHandle } from "./daemon.js"; import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js"; import { createSignalEventHandler } from "./monitor/event-handler.js"; @@ -272,7 +273,7 @@ function deriveSignalAttachmentRpcMaxResponseBytes(maxBytes: number): number | u async function fetchAttachment(params: { baseUrl: string; account?: string; - apiMode?: "native" | "container" | "auto"; + transportKind?: SignalTransportKind; attachment: SignalAttachment; sender?: string; groupId?: string; @@ -304,7 +305,7 @@ async function fetchAttachment(params: { const result = await signalRpcRequest<{ data?: string }>("getAttachment", rpcParams, { baseUrl: params.baseUrl, maxResponseBytes: deriveSignalAttachmentRpcMaxResponseBytes(params.maxBytes), - apiMode: params.apiMode, + transportKind: params.transportKind, }); if (!result?.data) { return null; @@ -569,15 +570,17 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi const reactionMode = accountInfo.config.reactionNotifications ?? "own"; const reactionAllowlist = normalizeStringEntries(accountInfo.config.reactionAllowlist); const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024; + const transportKind = accountInfo.transport.kind; + const managedTransport = + accountInfo.transport.kind === "managed-native" ? accountInfo.transport : undefined; const ignoreAttachments = opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments ?? false; const sendReadReceipts = Boolean(opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts); const waitForTransportReadyFn = opts.waitForTransportReady ?? waitForTransportReady; - const autoStart = opts.autoStart ?? accountInfo.config.autoStart ?? !accountInfo.config.httpUrl; - const configuredApiMode = cfg.channels?.signal?.apiMode ?? "auto"; + const autoStart = Boolean(managedTransport) && (opts.autoStart ?? true); const startupTimeoutMs = Math.min( 120_000, - Math.max(1_000, opts.startupTimeoutMs ?? accountInfo.config.startupTimeoutMs ?? 30_000), + Math.max(1_000, opts.startupTimeoutMs ?? managedTransport?.startupTimeoutMs ?? 30_000), ); const readReceiptsViaDaemon = autoStart && sendReadReceipts; const daemonLifecycle = createSignalDaemonLifecycle({ abortSignal: opts.abortSignal }); @@ -585,35 +588,22 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi let daemonHandle: SignalDaemonHandle | null = null; let ingressMonitor: SignalIngressMonitor | undefined; - if (autoStart && configuredApiMode === "container") { - throw new Error( - "channels.signal.autoStart=true is incompatible with channels.signal.apiMode=container", - ); - } - if (autoStart) { - const cliPath = opts.cliPath ?? accountInfo.config.cliPath ?? "signal-cli"; + const cliPath = opts.cliPath ?? managedTransport?.cliPath ?? "signal-cli"; const configPath = normalizeOptionalString(opts.configPath) ?? - normalizeOptionalString(accountInfo.config.configPath); - const daemonUrl = new URL(baseUrl); - const daemonHostname = daemonUrl.hostname; - const httpHost = - opts.httpHost ?? - (daemonHostname.startsWith("[") && daemonHostname.endsWith("]") - ? daemonHostname.slice(1, -1) - : daemonHostname); - const httpPort = - opts.httpPort ?? Number(daemonUrl.port || (daemonUrl.protocol === "https:" ? 443 : 80)); + normalizeOptionalString(managedTransport?.configPath); + const httpHost = opts.httpHost ?? managedTransport?.httpHost ?? "127.0.0.1"; + const httpPort = opts.httpPort ?? managedTransport?.httpPort ?? 8080; daemonHandle = spawnSignalDaemon({ cliPath, ...(configPath ? { configPath } : {}), account, httpHost, httpPort, - receiveMode: opts.receiveMode ?? accountInfo.config.receiveMode, + receiveMode: opts.receiveMode ?? managedTransport?.receiveMode, ignoreAttachments: opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments, - ignoreStories: opts.ignoreStories ?? accountInfo.config.ignoreStories, + ignoreStories: opts.ignoreStories ?? managedTransport?.ignoreStories, sendReadReceipts, runtime, }); @@ -684,7 +674,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi ignoreAttachments, sendReadReceipts, readReceiptsViaDaemon, - fetchAttachment: (params) => fetchAttachment({ ...params, apiMode: configuredApiMode }), + fetchAttachment: (params) => fetchAttachment({ ...params, transportKind }), deliverReplies: (params) => deliverReplies({ ...params, cfg, chunkMode }), resolveSignalReactionTargets, isSignalReactionMessage, @@ -705,7 +695,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi runtime, // signal-cli can keep the SSE event endpoint idle until the next inbound event. timeoutMs: 0, - apiMode: configuredApiMode, + transportKind, policy: opts.reconnectPolicy, onEvent: (event) => monitorTaskRunner.runTask(async () => await ingressMonitor?.receive(event)), diff --git a/extensions/signal/src/probe.ts b/extensions/signal/src/probe.ts index 15f4ada7df22..3566b6577bfe 100644 --- a/extensions/signal/src/probe.ts +++ b/extensions/signal/src/probe.ts @@ -1,7 +1,8 @@ // Signal plugin module implements probe behavior. import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { type SignalApiMode, signalCheck, signalRpcRequest } from "./client-adapter.js"; +import { type SignalTransportKind, signalCheck, signalRpcRequest } from "./client-adapter.js"; +import { detectSignalTransport } from "./transport-detection.js"; export type SignalProbe = BaseProbeResult & { status?: number | null; @@ -25,7 +26,11 @@ function parseSignalVersion(value: unknown): string | null { export async function probeSignal( baseUrl: string, timeoutMs: number, - options: { apiMode?: SignalApiMode } = {}, + options: { + transportKind?: SignalTransportKind; + /** @deprecated Pass transportKind after resolving the account transport. */ + apiMode?: "auto" | "native" | "container"; + } = {}, ): Promise { const started = Date.now(); const result: SignalProbe = { @@ -35,8 +40,17 @@ export async function probeSignal( elapsedMs: 0, version: null, }; - const apiMode = options.apiMode ?? "native"; - const check = await signalCheck(baseUrl, timeoutMs, { apiMode }); + let transportKind: SignalTransportKind; + try { + transportKind = await resolveProbeTransportKind(baseUrl, timeoutMs, options); + } catch (error) { + return { + ...result, + error: formatErrorMessage(error), + elapsedMs: Date.now() - started, + }; + } + const check = await signalCheck(baseUrl, timeoutMs, { transportKind }); if (!check.ok) { return { ...result, @@ -49,7 +63,7 @@ export async function probeSignal( const version = await signalRpcRequest("version", undefined, { baseUrl, timeoutMs, - apiMode, + transportKind, }); result.version = parseSignalVersion(version); } catch (err) { @@ -62,3 +76,23 @@ export async function probeSignal( elapsedMs: Date.now() - started, }; } + +async function resolveProbeTransportKind( + baseUrl: string, + timeoutMs: number, + options: { + transportKind?: SignalTransportKind; + apiMode?: "auto" | "native" | "container"; + }, +): Promise { + if (options.transportKind) { + return options.transportKind; + } + if (options.apiMode === "container") { + return "container"; + } + if (options.apiMode === "auto") { + return (await detectSignalTransport({ url: baseUrl, timeoutMs })).kind; + } + return "external-native"; +} diff --git a/extensions/signal/src/runtime-api.ts b/extensions/signal/src/runtime-api.ts index 2b10716c0975..86dfe0f5ebc8 100644 --- a/extensions/signal/src/runtime-api.ts +++ b/extensions/signal/src/runtime-api.ts @@ -40,6 +40,7 @@ export { listSignalAccountIds, resolveDefaultSignalAccountId, resolveSignalAccount, + resolveSignalTransport, } from "./accounts.js"; export { monitorSignalProvider } from "./monitor.js"; export { installSignalCli } from "./install-signal-cli.js"; @@ -48,5 +49,5 @@ export { resolveSignalReactionLevel } from "./reaction-level.js"; export { removeReactionSignal, sendReactionSignal } from "./send-reactions.js"; export { sendMessageSignal } from "./send.js"; export { signalMessageActions } from "./message-actions.js"; -export type { ResolvedSignalAccount } from "./accounts.js"; -export type { SignalAccountConfig } from "./account-types.js"; +export type { ResolvedSignalAccount, ResolvedSignalTransport } from "./accounts.js"; +export type { SignalAccountConfig, SignalTransportConfig } from "./account-types.js"; diff --git a/extensions/signal/src/send-reactions.test.ts b/extensions/signal/src/send-reactions.test.ts index 58a603d5ce07..d723199745a5 100644 --- a/extensions/signal/src/send-reactions.test.ts +++ b/extensions/signal/src/send-reactions.test.ts @@ -18,6 +18,14 @@ vi.mock("./accounts.js", () => ({ accountId: "default", enabled: true, baseUrl: "http://signal.local", + transport: { + kind: "managed-native", + baseUrl: "http://signal.local", + cliPath: "signal-cli", + httpHost: "127.0.0.1", + httpPort: 8080, + startupTimeoutMs: 30_000, + }, configured: true, config: { account: "+15550001111" }, }), @@ -78,7 +86,7 @@ describe("sendReactionSignal", () => { { baseUrl: "http://signal.local", timeoutMs: undefined, - apiMode: undefined, + transportKind: "managed-native", }, ); const params = requireRpcParams(); @@ -102,6 +110,23 @@ describe("sendReactionSignal", () => { expect(params.targetAuthor).toBe("123e4567-e89b-12d3-a456-426614174000"); }); + it("honors an explicit container endpoint override", async () => { + await sendReactionSignal("+15551230000", 123, "✅", { + cfg: SIGNAL_TEST_CFG, + baseUrl: "http://container:8080", + transportKind: "container", + }); + + expect(rpcMock).toHaveBeenCalledWith( + "sendReaction", + expect.any(Object), + expect.objectContaining({ + baseUrl: "http://container:8080", + transportKind: "container", + }), + ); + }); + it("defaults targetAuthor to recipient for removals", async () => { await removeReactionSignal("+15551230000", 456, "❌", { cfg: SIGNAL_TEST_CFG }); diff --git a/extensions/signal/src/send-reactions.ts b/extensions/signal/src/send-reactions.ts index e10c6e2a012a..bc14936514b6 100644 --- a/extensions/signal/src/send-reactions.ts +++ b/extensions/signal/src/send-reactions.ts @@ -6,12 +6,13 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; import { resolveSignalAccount } from "./accounts.js"; -import { signalRpcRequest } from "./client-adapter.js"; +import { signalRpcRequest, type SignalTransportKind } from "./client-adapter.js"; import { resolveSignalRpcContext } from "./rpc-context.js"; export type SignalReactionOpts = { cfg: OpenClawConfig; baseUrl?: string; + transportKind?: SignalTransportKind; account?: string; accountId?: string; timeoutMs?: number; @@ -79,7 +80,6 @@ async function sendReactionSignalCore(params: { errors: SignalReactionErrorMessages; }): Promise { const cfg = requireRuntimeConfig(params.opts.cfg, "Signal reactions"); - const apiMode = cfg.channels?.signal?.apiMode; const accountInfo = resolveSignalAccount({ cfg, accountId: params.opts.accountId, @@ -127,7 +127,7 @@ async function sendReactionSignalCore(params: { const result = await signalRpcRequest<{ timestamp?: number }>("sendReaction", requestParams, { baseUrl, timeoutMs: params.opts.timeoutMs, - apiMode, + transportKind: params.opts.transportKind ?? accountInfo.transport.kind, }); return { diff --git a/extensions/signal/src/send.test.ts b/extensions/signal/src/send.test.ts index 7ff0ba14e4ce..81666761f297 100644 --- a/extensions/signal/src/send.test.ts +++ b/extensions/signal/src/send.test.ts @@ -1,4 +1,5 @@ // Signal tests cover send plugin behavior. +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { beforeEach, describe, expect, it, vi } from "vitest"; const signalRpcRequestMock = vi.hoisted(() => vi.fn()); @@ -28,20 +29,20 @@ const { clearSignalApprovalReactionTargetsForTest, resolveSignalApprovalReactionTargetWithPersistence, } = await import("./approval-reactions.js"); -const { sendMessageSignal } = await import("./send.js"); +const { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } = await import("./send.js"); const SIGNAL_TEST_CFG = { channels: { signal: { accounts: { default: { - httpUrl: "http://signal.test", + transport: { kind: "external-native", url: "http://signal.test" }, account: "+15550001111", }, }, }, }, -}; +} satisfies OpenClawConfig; describe("sendMessageSignal receipts", () => { beforeEach(() => { @@ -50,6 +51,34 @@ describe("sendMessageSignal receipts", () => { resolveOutboundAttachmentFromUrlMock.mockClear(); }); + it("routes a named container account across send, typing, and receipt RPCs", async () => { + signalRpcRequestMock.mockResolvedValue({ timestamp: 1234567890 }); + const cfg = { + channels: { + signal: { + accounts: { + work: { + account: "+15550001111", + transport: { kind: "container" as const, url: "http://container:8080" }, + }, + }, + }, + }, + }; + + await sendMessageSignal("+15551234567", "hello", { cfg, accountId: "work" }); + await sendTypingSignal("+15551234567", { cfg, accountId: "work" }); + await sendReadReceiptSignal("+15551234567", 1234567890, { cfg, accountId: "work" }); + + expect(signalRpcRequestMock).toHaveBeenCalledTimes(3); + for (const call of signalRpcRequestMock.mock.calls) { + expect(call[2]).toMatchObject({ + baseUrl: "http://container:8080", + transportKind: "container", + }); + } + }); + it("attaches a text receipt for timestamp results", async () => { signalRpcRequestMock.mockResolvedValueOnce({ timestamp: 1234567890 }); @@ -340,7 +369,7 @@ describe("sendMessageSignal receipts", () => { signal: { accounts: { default: { - httpUrl: "http://signal.test", + transport: { kind: "external-native" as const, url: "http://signal.test" }, account: "+15550001111", }, }, @@ -376,7 +405,7 @@ describe("sendMessageSignal receipts", () => { signal: { accounts: { default: { - httpUrl: "http://signal.test", + transport: { kind: "external-native" as const, url: "http://signal.test" }, account: "+15550001111", allowFrom: ["+15551234567"], }, @@ -424,7 +453,7 @@ describe("sendMessageSignal receipts", () => { signal: { accounts: { default: { - httpUrl: "http://signal.test", + transport: { kind: "external-native" as const, url: "http://signal.test" }, account: "+15550001111", allowFrom: ["+15551234567"], }, @@ -485,7 +514,7 @@ describe("sendMessageSignal receipts", () => { signal: { accounts: { default: { - httpUrl: "http://signal.test", + transport: { kind: "external-native" as const, url: "http://signal.test" }, account: "+15550001111", allowFrom: ["+15551234567"], }, @@ -529,7 +558,7 @@ describe("sendMessageSignal receipts", () => { signal: { accounts: { default: { - httpUrl: "http://signal.test", + transport: { kind: "external-native" as const, url: "http://signal.test" }, account: "+15550001111", allowFrom: ["+15551234567"], }, @@ -580,7 +609,7 @@ describe("sendMessageSignal receipts", () => { signal: { accounts: { default: { - httpUrl: "http://signal.test", + transport: { kind: "external-native" as const, url: "http://signal.test" }, accountUuid: "123e4567-e89b-12d3-a456-426614174000", allowFrom: ["+15551234567"], }, diff --git a/extensions/signal/src/send.ts b/extensions/signal/src/send.ts index 4213bdc8d2a8..55d5e18a5b39 100644 --- a/extensions/signal/src/send.ts +++ b/extensions/signal/src/send.ts @@ -20,7 +20,7 @@ import { appendSignalApprovalReactionHintForOutboundMessage, registerSignalApprovalReactionTargetForOutboundMessage, } from "./approval-reactions.js"; -import { signalRpcRequest } from "./client-adapter.js"; +import { signalRpcRequest, type SignalTransportKind } from "./client-adapter.js"; import { markdownToSignalText, type SignalTextStyleRange } from "./format.js"; import { normalizeSignalMessagingTarget } from "./normalize.js"; import { registerSignalReplyContext } from "./reply-authors.js"; @@ -29,6 +29,7 @@ import { resolveSignalRpcContext } from "./rpc-context.js"; export type SignalSendOpts = { cfg: OpenClawConfig; baseUrl?: string; + transportKind?: SignalTransportKind; account?: string; accountId?: string; mediaUrl?: string; @@ -55,7 +56,7 @@ export type SignalSendResult = { export type SignalRpcOpts = Pick< SignalSendOpts, - "cfg" | "baseUrl" | "account" | "accountId" | "timeoutMs" + "cfg" | "baseUrl" | "transportKind" | "account" | "accountId" | "timeoutMs" >; export type SignalReceiptType = "read" | "viewed"; @@ -66,9 +67,6 @@ type SignalTarget = | { type: "username"; username: string }; async function resolveSignalRpcAccountInfo(opts: SignalRpcOpts) { - if (opts.baseUrl?.trim() && opts.account?.trim()) { - return undefined; - } if (!opts.cfg) { throw new Error( "Signal RPC account resolution requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.", @@ -238,7 +236,6 @@ export async function sendMessageSignal( opts: SignalSendOpts, ): Promise { const cfg = requireRuntimeConfig(opts.cfg, "Signal send"); - const apiMode = cfg.channels?.signal?.apiMode; const accountInfo = resolveSignalAccount({ cfg, accountId: opts.accountId, @@ -332,7 +329,7 @@ export async function sendMessageSignal( const sendOpts = { baseUrl, timeoutMs: opts.timeoutMs, - apiMode, + transportKind: opts.transportKind ?? accountInfo.transport.kind, maxAttachmentBytes: maxBytes, }; let nativeReplyStatus: "sent" | "fallback" | undefined; @@ -396,7 +393,6 @@ export async function sendTypingSignal( opts: SignalRpcOpts & { stop?: boolean }, ): Promise { const accountInfo = await resolveSignalRpcAccountInfo(opts); - const cfg = requireRuntimeConfig(opts.cfg, "Signal typing"); const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo); const targetParams = buildTargetParams(parseTarget(to), { recipient: true, @@ -415,7 +411,7 @@ export async function sendTypingSignal( await signalRpcRequest("sendTyping", params, { baseUrl, timeoutMs: opts.timeoutMs, - apiMode: cfg.channels?.signal?.apiMode, + transportKind: opts.transportKind ?? accountInfo.transport.kind, }); return true; } @@ -429,7 +425,6 @@ export async function sendReadReceiptSignal( return false; } const accountInfo = await resolveSignalRpcAccountInfo(opts); - const cfg = requireRuntimeConfig(opts.cfg, "Signal read receipt"); const { baseUrl, account } = resolveSignalRpcContext(opts, accountInfo); const targetParams = buildTargetParams(parseTarget(to), { recipient: true, @@ -448,7 +443,7 @@ export async function sendReadReceiptSignal( await signalRpcRequest("sendReceipt", params, { baseUrl, timeoutMs: opts.timeoutMs, - apiMode: cfg.channels?.signal?.apiMode, + transportKind: opts.transportKind ?? accountInfo.transport.kind, }); return true; } diff --git a/extensions/signal/src/setup-core.test.ts b/extensions/signal/src/setup-core.test.ts new file mode 100644 index 000000000000..4a4f4e1752db --- /dev/null +++ b/extensions/signal/src/setup-core.test.ts @@ -0,0 +1,559 @@ +// Signal tests cover setup adapter integration with account-owned transport policy. +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createSignalCliPathTextInput, signalSetupAdapter } from "./setup-core.js"; +import { signalSetupWizard } from "./setup-surface.js"; + +const detectSignalTransportMock = vi.hoisted(() => vi.fn()); + +vi.mock("./setup-transport.js", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, detectSignalTransport: detectSignalTransportMock }; +}); + +async function prepareInput( + input: { + signalNumber?: string; + signalTransport?: "external-native" | "container"; + httpUrl?: string; + }, + cfg: OpenClawConfig = {}, +) { + const prepared = await signalSetupAdapter.prepareAccountConfigInput?.({ + cfg, + accountId: "default", + input, + runtime: {} as never, + }); + if (!prepared) { + throw new Error("expected prepared Signal setup input"); + } + return prepared; +} + +describe("signalSetupAdapter", () => { + beforeEach(() => { + detectSignalTransportMock.mockReset(); + }); + + it("uses setup-time container detection for a bare HTTP URL", async () => { + detectSignalTransportMock.mockResolvedValue({ + kind: "container", + url: "http://signal:8080", + }); + + const input = await prepareInput({ + signalNumber: "+15555550123", + httpUrl: "http://signal:8080", + }); + const next = signalSetupAdapter.applyAccountConfig?.({ + cfg: {}, + accountId: "default", + input, + }); + + expect(detectSignalTransportMock).toHaveBeenCalledWith({ + url: "http://signal:8080", + account: "+15555550123", + }); + expect(next?.channels?.signal?.transport).toEqual({ + kind: "container", + url: "http://signal:8080", + }); + }); + + it("requires an explicit kind when bare HTTP URL detection is unreachable", async () => { + detectSignalTransportMock.mockRejectedValue(new Error("unreachable")); + + await expect(prepareInput({ httpUrl: "http://signal:8080" })).rejects.toThrow( + "Signal could not detect the HTTP transport; start the endpoint or pass --signal-transport external-native|container.", + ); + }); + + it("preserves an existing container account when detection is unreachable", async () => { + detectSignalTransportMock.mockRejectedValue(new Error("unreachable")); + const cfg: OpenClawConfig = { + channels: { + signal: { + account: "+15555550123", + transport: { kind: "container", url: "http://signal-old:8080" }, + }, + }, + }; + + const input = await prepareInput({ httpUrl: "http://signal-new:8080" }, cfg); + const next = signalSetupAdapter.applyAccountConfig?.({ + cfg, + accountId: "default", + input, + }); + + expect(input.signalTransport).toBeUndefined(); + expect(next?.channels?.signal?.transport).toEqual({ + kind: "container", + url: "http://signal-new:8080", + }); + }); + + it("skips setup-time detection for an explicit transport kind", async () => { + const input = await prepareInput({ + signalNumber: "+15555550123", + signalTransport: "container", + httpUrl: "http://signal:8080", + }); + + expect(input.signalTransport).toBe("container"); + expect(detectSignalTransportMock).not.toHaveBeenCalled(); + }); + + it("restores a generically promoted default account before writing a named account", () => { + const next = signalSetupAdapter.applyAccountConfig?.({ + cfg: { + channels: { + signal: { + transport: { kind: "managed-native", httpPort: 8080 }, + accounts: { + default: { account: "+15555550123" }, + }, + }, + }, + }, + accountId: "work", + input: { signalNumber: "+15555550124" }, + }); + + expect(next?.channels?.signal?.account).toBe("+15555550123"); + expect(next?.channels?.signal?.transport).toEqual({ + kind: "managed-native", + httpPort: 8080, + }); + expect(next?.channels?.signal?.accounts?.default).toBeUndefined(); + expect(next?.channels?.signal?.accounts?.work?.transport).toEqual({ + kind: "managed-native", + httpHost: "127.0.0.1", + httpPort: 8081, + }); + }); + + it("keeps promoted default-account policy scoped to that account", () => { + const next = signalSetupAdapter.applyAccountConfig?.({ + cfg: { + channels: { + signal: { + dmPolicy: "pairing", + transport: { kind: "managed-native", httpPort: 8080 }, + accounts: { + default: { + account: "+15555550123", + dmPolicy: "disabled", + allowFrom: ["+15555550125"], + }, + }, + }, + }, + }, + accountId: "work", + input: { signalNumber: "+15555550124" }, + }); + + expect(next?.channels?.signal?.account).toBe("+15555550123"); + expect(next?.channels?.signal?.dmPolicy).toBe("pairing"); + expect(next?.channels?.signal?.allowFrom).toBeUndefined(); + expect(next?.channels?.signal?.accounts?.default).toMatchObject({ + dmPolicy: "disabled", + allowFrom: ["+15555550125"], + }); + expect(next?.channels?.signal?.accounts?.default).not.toHaveProperty("account"); + }); + + it("repairs a duplicate explicit managed port before runtime resolution", () => { + const next = signalSetupAdapter.applyAccountConfig?.({ + cfg: { + channels: { + signal: { + accounts: { + personal: { + account: "+15555550123", + transport: { kind: "managed-native", httpPort: 8181 }, + }, + work: { + account: "+15555550124", + transport: { kind: "managed-native", httpPort: 8181 }, + }, + }, + }, + }, + }, + accountId: "work", + input: { httpPort: "8282" }, + }); + + expect(next?.channels?.signal?.accounts?.work?.transport).toMatchObject({ + kind: "managed-native", + httpPort: 8282, + }); + }); + + it("realigns an existing managed connection URL after a partial bind update", () => { + const next = signalSetupAdapter.applyAccountConfig?.({ + cfg: { + channels: { + signal: { + accounts: { + work: { + account: "+15555550124", + transport: { + kind: "managed-native", + url: "http://127.0.0.1:8181", + httpHost: "127.0.0.1", + httpPort: 8181, + }, + }, + }, + }, + }, + }, + accountId: "work", + input: { httpHost: "127.0.0.2", httpPort: "8282" }, + }); + + expect(next?.channels?.signal?.accounts?.work?.transport).toMatchObject({ + kind: "managed-native", + url: "http://127.0.0.2:8282", + httpHost: "127.0.0.2", + httpPort: 8282, + }); + }); + + it("uses the setup transport allocator for a second managed account", () => { + const cfg: OpenClawConfig = { + channels: { + signal: { + account: "+15555550123", + transport: { kind: "managed-native", httpPort: 8080 }, + accounts: { work: { account: "+15555550124" } }, + }, + }, + }; + + const next = signalSetupAdapter.applyAccountConfig?.({ + cfg, + accountId: "work", + input: { signalNumber: "+15555550124" }, + }); + + expect(next?.channels?.signal?.accounts?.work?.transport).toEqual({ + kind: "managed-native", + httpHost: "127.0.0.1", + httpPort: 8081, + }); + }); + + it("preserves managed transport options during a partial setup update", () => { + const cfg: OpenClawConfig = { + channels: { + signal: { + accounts: { + work: { + account: "+15555550124", + transport: { + kind: "managed-native", + cliPath: "/opt/old-signal-cli", + configPath: "/var/lib/signal-work", + httpHost: "127.0.0.2", + httpPort: 8181, + receiveMode: "manual", + ignoreStories: true, + }, + }, + }, + }, + }, + }; + + const next = signalSetupAdapter.applyAccountConfig?.({ + cfg, + accountId: "work", + input: { cliPath: "/opt/new-signal-cli" }, + }); + + expect(next?.channels?.signal?.accounts?.work?.transport).toEqual({ + kind: "managed-native", + cliPath: "/opt/new-signal-cli", + configPath: "/var/lib/signal-work", + httpHost: "127.0.0.2", + httpPort: 8181, + receiveMode: "manual", + ignoreStories: true, + }); + }); + + it("makes a new default transport update authoritative over accounts.default", () => { + const cfg: OpenClawConfig = { + channels: { + signal: { + accounts: { + default: { + account: "+15555550124", + transport: { kind: "external-native", url: "http://old-signal:8080" }, + }, + }, + }, + }, + }; + + const next = signalSetupAdapter.applyAccountConfig?.({ + cfg, + accountId: "default", + input: { cliPath: "/opt/new-signal-cli" }, + }); + + expect(next?.channels?.signal?.transport).toEqual({ + kind: "managed-native", + cliPath: "/opt/new-signal-cli", + httpHost: "127.0.0.1", + httpPort: 8080, + }); + expect(next?.channels?.signal?.accounts?.default).not.toHaveProperty("transport"); + }); + + it("keeps the canonical root transport during a default account-only update", () => { + const cfg: OpenClawConfig = { + channels: { + signal: { + transport: { kind: "external-native", url: "http://canonical-signal:8080" }, + accounts: { + default: { + account: "+15555550124", + transport: { kind: "container", url: "http://stale-container:8080" }, + }, + }, + }, + }, + }; + + const next = signalSetupAdapter.applyAccountConfig?.({ + cfg, + accountId: "default", + input: { signalNumber: "+15555550125" }, + }); + + expect(next?.channels?.signal?.transport).toEqual({ + kind: "external-native", + url: "http://canonical-signal:8080", + }); + expect(next?.channels?.signal?.accounts?.default).toBeUndefined(); + }); + + it("stores an explicitly selected container endpoint", () => { + const next = signalSetupAdapter.applyAccountConfig?.({ + cfg: {}, + accountId: "work", + input: { + signalNumber: "+15555550124", + httpUrl: "http://signal-container:8080/", + signalTransport: "container", + }, + }); + + expect(next?.channels?.signal?.accounts?.work?.transport).toEqual({ + kind: "container", + url: "http://signal-container:8080", + }); + }); + + it("keeps bare HTTP URLs on the historical external-native transport", () => { + const next = signalSetupAdapter.applyAccountConfig?.({ + cfg: {}, + accountId: "work", + input: { + signalNumber: "+15555550124", + httpUrl: "signal-native:8080", + }, + }); + + expect(next?.channels?.signal?.accounts?.work?.transport).toEqual({ + kind: "external-native", + url: "http://signal-native:8080", + }); + }); + + it("preserves an existing container kind when only its URL changes", () => { + const next = signalSetupAdapter.applyAccountConfig?.({ + cfg: { + channels: { + signal: { + account: "+15555550123", + accounts: { + work: { + transport: { kind: "container", url: "http://old-container:8080" }, + }, + }, + }, + }, + }, + accountId: "work", + input: { httpUrl: "http://new-container:8080/" }, + }); + + expect(next?.channels?.signal?.accounts?.work?.transport).toEqual({ + kind: "container", + url: "http://new-container:8080", + }); + }); + + it("preserves a nested default container kind while canonicalizing a URL-only edit", () => { + const next = signalSetupAdapter.applyAccountConfig?.({ + cfg: { + channels: { + signal: { + accounts: { + Default: { + account: "+15555550123", + transport: { kind: "container", url: "http://old-container:8080" }, + }, + }, + }, + }, + }, + accountId: "default", + input: { httpUrl: "http://new-container:8080/" }, + }); + + expect(next?.channels?.signal?.transport).toEqual({ + kind: "container", + url: "http://new-container:8080", + }); + expect(next?.channels?.signal?.accounts?.Default).not.toHaveProperty("transport"); + }); + + it.each(["0", "abc", "65536"])("rejects invalid managed HTTP port %s", (httpPort) => { + expect( + signalSetupAdapter.validateInput?.({ + cfg: {}, + accountId: "work", + input: { httpPort }, + }), + ).toBe("Signal --http-port must be an integer between 1 and 65535."); + }); + + it.each(["bad host", "host/path", "[::1", "localhost:8181", "bad:host"])( + "rejects invalid managed HTTP host %s", + (httpHost) => { + expect( + signalSetupAdapter.validateInput?.({ + cfg: {}, + accountId: "work", + input: { httpHost }, + }), + ).toBe("Signal --http-host must be a hostname or IP address."); + }, + ); + + it("rejects a transport kind without an HTTP URL", () => { + expect( + signalSetupAdapter.validateInput?.({ + cfg: {}, + accountId: "work", + input: { signalTransport: "container" }, + }), + ).toBe("Signal --signal-transport requires --http-url."); + }); + + it("rejects a fresh container transport without a Signal account", () => { + expect( + signalSetupAdapter.validateInput?.({ + cfg: {}, + accountId: "work", + input: { + httpUrl: "http://signal-container:8080", + signalTransport: "container", + }, + }), + ).toBe("Signal container transport requires --signal-number or an existing account."); + }); + + it("allows a container transport to reuse the configured Signal account", () => { + expect( + signalSetupAdapter.validateInput?.({ + cfg: { + channels: { + signal: { + accounts: { work: { account: "+15555550124" } }, + }, + }, + }, + accountId: "work", + input: { + httpUrl: "http://signal-container:8080", + signalTransport: "container", + }, + }), + ).toBeNull(); + }); + + it("allows a named container transport to inherit the root Signal account", () => { + expect( + signalSetupAdapter.validateInput?.({ + cfg: { + channels: { + signal: { account: "+15555550123" }, + }, + }, + accountId: "work", + input: { + httpUrl: "http://signal-container:8080", + signalTransport: "container", + }, + }), + ).toBeNull(); + }); + + it("does not materialize a CLI path for an external transport", async () => { + const input = createSignalCliPathTextInput(async () => false); + const cfg: OpenClawConfig = { + channels: { + signal: { + account: "+15555550124", + transport: { kind: "container", url: "http://signal:8080" }, + }, + }, + }; + + expect( + await input.currentValue?.({ cfg, accountId: "default", credentialValues: {} }), + ).toBeUndefined(); + const wizardInput = signalSetupWizard.textInputs?.find((entry) => entry.inputKey === "cliPath"); + expect( + await wizardInput?.shouldPrompt?.({ + cfg, + accountId: "default", + credentialValues: {}, + }), + ).toBe(false); + }); + + it("reports an external transport as configured without checking signal-cli", async () => { + const cfg: OpenClawConfig = { + channels: { + signal: { + account: "+15555550124", + transport: { kind: "external-native", url: "http://signal:8080" }, + }, + }, + }; + const configured = await signalSetupWizard.status.resolveConfigured({ + cfg, + accountId: "default", + }); + const params = { cfg, accountId: "default", configured }; + + await expect(signalSetupWizard.status.resolveStatusLines?.(params)).resolves.toEqual([ + "Signal: configured", + ]); + await expect(signalSetupWizard.status.resolveSelectionHint?.(params)).resolves.toBe( + "configured", + ); + await expect(signalSetupWizard.status.resolveQuickstartScore?.(params)).resolves.toBe(1); + }); +}); diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index 0795bba556ae..23c3b9ae6b21 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -1,5 +1,6 @@ // Signal plugin module implements setup core behavior. -import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup"; +import { normalizeAccountId, resolveAccountEntry } from "openclaw/plugin-sdk/account-resolution"; +import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; import { createCliPathTextInput, createDelegatedSetupWizardProxy, @@ -26,24 +27,66 @@ import { normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; import { normalizeE164 } from "openclaw/plugin-sdk/text-utility-runtime"; +import type { SignalTransportConfig } from "./account-types.js"; import { resolveDefaultSignalAccountId, resolveSignalAccount } from "./accounts.js"; +import { + detectSignalTransport, + prepareSignalManagedNativeTransport, + resolveConfiguredSignalTransport, + writeSignalAccountTransport, +} from "./setup-transport.js"; +import { isValidSignalManagedNativePort } from "./transport-policy.js"; +import { normalizeSignalTransportHost, normalizeSignalTransportUrl } from "./transport-url.js"; const t = createSetupTranslator(); const channel = "signal" as const; -const MIN_E164_DIGITS = 5; -const MAX_E164_DIGITS = 15; -const DIGITS_ONLY = /^\d+$/; -const INVALID_SIGNAL_ACCOUNT_ERROR = - "Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)"; -type SignalSetupInput = ChannelSetupInput & { +const signalSetupFields = { + signalNumber: { + kind: "string", + cli: { flags: "--signal-number ", description: "Signal account number (E.164)" }, + }, + signalTransport: { + kind: "choice", + choices: ["external-native", "container"], + cli: { + flags: "--signal-transport ", + description: "Signal HTTP transport (external-native or container)", + }, + }, + cliPath: { + kind: "string", + cli: { flags: "--cli-path ", description: "signal-cli executable path" }, + }, + httpUrl: { + kind: "string", + cli: { flags: "--http-url ", description: "Signal HTTP service URL" }, + }, + httpHost: { + kind: "string", + cli: { flags: "--http-host ", description: "Signal HTTP daemon host" }, + }, + httpPort: { + kind: "string", + cli: { flags: "--http-port ", description: "Signal HTTP daemon port" }, + }, +} as const; + +type SignalSetupInput = { + name?: string; signalNumber?: string; + signalTransport?: "external-native" | "container"; cliPath?: string; httpUrl?: string; httpHost?: string; httpPort?: string; }; +const MIN_E164_DIGITS = 5; +const MAX_E164_DIGITS = 15; +const DIGITS_ONLY = /^\d+$/; +const INVALID_SIGNAL_ACCOUNT_ERROR = + "Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)"; export function normalizeSignalAccountInput(value: string | null | undefined): string | null { const trimmed = normalizeOptionalString(value); @@ -92,24 +135,85 @@ function parseSignalAllowFromEntries(raw: string): { entries: string[]; error?: } export function buildSignalSetupPatch(input: SignalSetupInput) { - const rawHttpHost = input.httpHost || "127.0.0.1"; - const httpHost = - rawHttpHost.includes(":") && !rawHttpHost.startsWith("[") ? `[${rawHttpHost}]` : rawHttpHost; - const derivedHttpUrl = - input.httpUrl ?? - (input.httpHost || input.httpPort - ? `http://${httpHost}:${input.httpPort || "8080"}` - : undefined); + const transport = input.httpUrl + ? { + // Bare --http-url is classified once by prepareAccountConfigInput. Keep the historical + // external-native default for direct adapter callers that already bypass preparation. + kind: input.signalTransport ?? ("external-native" as const), + url: normalizeSignalTransportUrl(input.httpUrl), + } + : input.cliPath || input.httpHost || input.httpPort + ? { + kind: "managed-native" as const, + ...(input.cliPath ? { cliPath: input.cliPath } : {}), + ...(input.httpHost ? { httpHost: input.httpHost } : {}), + ...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}), + } + : undefined; return { ...(input.signalNumber ? { account: input.signalNumber } : {}), - ...(input.cliPath ? { cliPath: input.cliPath } : {}), - ...(derivedHttpUrl ? { httpUrl: derivedHttpUrl } : {}), - // Legacy host/port selected the locally owned daemon; httpUrl selected an - // external endpoint. Preserve that lifecycle distinction while unifying URLs. - ...(input.httpUrl ? { autoStart: false } : derivedHttpUrl ? { autoStart: true } : {}), + ...(transport ? { transport } : {}), }; } +async function prepareSignalSetupInput(params: { + cfg: OpenClawConfig; + accountId: string; + input: SignalSetupInput; +}): Promise { + if (!params.input.httpUrl || params.input.signalTransport) { + return params.input; + } + const account = + normalizeSignalAccountInput(params.input.signalNumber) ?? + normalizeSignalAccountInput( + resolveSignalSetupAccount({ cfg: params.cfg, accountId: params.accountId }), + ) ?? + undefined; + try { + const detected = await detectSignalTransport({ + url: params.input.httpUrl, + ...(account ? { account } : {}), + }); + return { + ...params.input, + signalTransport: detected.kind === "container" ? "container" : "external-native", + }; + } catch { + const existing = resolveConfiguredSignalTransport(params.cfg, params.accountId); + if (existing?.kind === "container" || existing?.kind === "external-native") { + // Leave the kind unset so applyAccountConfig preserves the established protocol while + // changing only its URL. A fresh account has no such fact and must choose explicitly. + return params.input; + } + throw new Error( + "Signal could not detect the HTTP transport; start the endpoint or pass --signal-transport external-native|container.", + ); + } +} + +function managedTransportOverridesFromSetupInput( + input: SignalSetupInput, +): Omit, "kind"> { + return { + ...(input.cliPath ? { cliPath: input.cliPath } : {}), + ...(input.httpHost ? { httpHost: input.httpHost } : {}), + ...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}), + }; +} + +function resolveSignalSetupAccount(params: { + cfg: OpenClawConfig; + accountId?: string; +}): string | undefined { + const accountId = normalizeAccountId( + params.accountId ?? resolveDefaultSignalAccountId(params.cfg), + ); + const signal = params.cfg.channels?.signal; + const account = resolveAccountEntry(signal?.accounts, accountId); + return account?.account ?? signal?.account; +} + async function promptSignalAllowFrom(params: { cfg: OpenClawConfig; prompter: WizardPrompter; @@ -195,13 +299,16 @@ function resolveSignalCliPath(params: { accountId: string; credentialValues: Record; }) { - return ( - (typeof params.credentialValues.cliPath === "string" - ? params.credentialValues.cliPath - : undefined) ?? - resolveSignalAccount({ cfg: params.cfg, accountId: params.accountId }).config.cliPath ?? - "signal-cli" - ); + const transport = resolveSignalAccount({ + cfg: params.cfg, + accountId: params.accountId, + }).transport; + if (transport.kind !== "managed-native") { + return undefined; + } + return typeof params.credentialValues.cliPath === "string" + ? params.credentialValues.cliPath + : transport.cliPath; } export function createSignalCliPathTextInput( @@ -238,26 +345,82 @@ export const signalCompletionNote = { ], }; -export const signalSetupAdapter: ChannelSetupAdapter = { - ...createPatchedAccountSetupAdapter({ - channelKey: channel, - validateInput: createSetupInputPresenceValidator({ - validate: ({ input }) => { - const setupInput = input as SignalSetupInput; - if ( - !setupInput.signalNumber && - !setupInput.httpUrl && - !setupInput.httpHost && - !setupInput.httpPort && - !setupInput.cliPath - ) { - return "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path."; +const signalSetupAdapterBase = createPatchedAccountSetupAdapter({ + channelKey: channel, + validateInput: createSetupInputPresenceValidator({ + validate: ({ cfg, accountId, input }) => { + if ( + input.signalTransport && + input.signalTransport !== "external-native" && + input.signalTransport !== "container" + ) { + return "Signal --signal-transport must be external-native or container."; + } + if (input.signalTransport && !input.httpUrl) { + return "Signal --signal-transport requires --http-url."; + } + if (input.httpPort !== undefined && !isValidSignalManagedNativePort(Number(input.httpPort))) { + return "Signal --http-port must be an integer between 1 and 65535."; + } + if (input.httpHost) { + try { + normalizeSignalTransportHost(input.httpHost); + } catch { + return "Signal --http-host must be a hostname or IP address."; } - return null; - }, - }), - buildPatch: (input) => buildSignalSetupPatch(input as SignalSetupInput), + } + if ( + input.signalTransport === "container" && + !normalizeSignalAccountInput(input.signalNumber) && + !normalizeSignalAccountInput(resolveSignalSetupAccount({ cfg, accountId })) + ) { + return "Signal container transport requires --signal-number or an existing account."; + } + if ( + !input.signalNumber && + !input.httpUrl && + !input.httpHost && + !input.httpPort && + !input.cliPath + ) { + return "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path."; + } + return null; + }, }), + buildPatch: (input) => buildSignalSetupPatch(input), +}); + +function restorePromotedSignalDefaultAccount(cfg: OpenClawConfig): OpenClawConfig { + const signal = cfg.channels?.signal; + const promoted = signal?.accounts?.[DEFAULT_ACCOUNT_ID]; + if (!signal?.transport || signal.account || !promoted?.account) { + return cfg; + } + const { account, transport: _shadowedTransport, ...remainingDefault } = promoted; + const accounts = { ...signal.accounts }; + if (Object.keys(remainingDefault).length === 0) { + delete accounts[DEFAULT_ACCOUNT_ID]; + } else { + accounts[DEFAULT_ACCOUNT_ID] = remainingDefault; + } + return { + ...cfg, + channels: { + ...cfg.channels, + signal: { + ...signal, + account, + accounts, + }, + }, + }; +} + +export const signalSetupAdapter: ChannelSetupAdapter = { + ...signalSetupAdapterBase, + prepareAccountConfigInput: ({ cfg, accountId, input }) => + prepareSignalSetupInput({ cfg, accountId, input }), singleAccountKeysToMove: [ "signalNumber", "account", @@ -266,8 +429,45 @@ export const signalSetupAdapter: ChannelSetupAdapter = { "httpHost", "httpPort", ], + applyAccountConfig: (params) => { + const accountId = normalizeAccountId(params.accountId); + // Generic multi-account setup can promote the root account but not its owner-specific + // transport. Rejoin that pair here so Signal keeps one canonical default-account shape. + const cfg = restorePromotedSignalDefaultAccount(params.cfg); + const previousTransport = resolveConfiguredSignalTransport(cfg, accountId); + const next = signalSetupAdapterBase.applyAccountConfig?.({ ...params, cfg, accountId }) ?? cfg; + const configuredTransport = resolveConfiguredSignalTransport(next, accountId); + if (configuredTransport && configuredTransport.kind !== "managed-native") { + const transport = + params.input.httpUrl && + !params.input.signalTransport && + (previousTransport?.kind === "container" || previousTransport?.kind === "external-native") + ? { ...configuredTransport, kind: previousTransport.kind } + : configuredTransport; + return writeSignalAccountTransport({ + cfg: next, + accountId, + transport, + }); + } + return writeSignalAccountTransport({ + cfg: next, + accountId, + transport: prepareSignalManagedNativeTransport({ + // Use pre-patch transport state so aligned connection URLs can follow authored bind edits. + cfg, + accountId, + overrides: managedTransportOverridesFromSetupInput(params.input), + }), + }); + }, }; +export const signalSetupContract = defineChannelSetupContract({ + fields: signalSetupFields, + adapter: signalSetupAdapter, +}); + export function createSignalSetupWizardProxy(loadWizard: () => Promise) { return createDelegatedSetupWizardProxy({ channel, diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index aa3530383b81..315c6de3bce8 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -18,35 +18,65 @@ import { const t = createSetupTranslator(); const channel = "signal" as const; +const configuredLabel = t("wizard.channels.statusConfigured"); +const unconfiguredLabel = t("wizard.channels.statusNeedsSetup"); +const managedStatus = createDetectedBinaryStatus({ + channelLabel: "Signal", + binaryLabel: "signal-cli", + configuredLabel, + unconfiguredLabel, + configuredHint: t("wizard.channels.statusSignalCliFound"), + unconfiguredHint: t("wizard.channels.statusSignalCliMissing"), + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg, accountId }) => + accountId + ? resolveSignalAccount({ cfg, accountId }).configured + : listSignalAccountIds(cfg).some( + (resolvedAccountId) => + resolveSignalAccount({ cfg, accountId: resolvedAccountId }).configured, + ), + resolveBinaryPath: ({ cfg, accountId }) => { + const transport = resolveSignalAccount({ cfg, accountId }).transport; + return transport.kind === "managed-native" ? transport.cliPath : "signal-cli"; + }, + detectBinary, +}); + export const signalSetupWizard: ChannelSetupWizard = { channel, - status: createDetectedBinaryStatus({ - channelLabel: "Signal", - binaryLabel: "signal-cli", - configuredLabel: t("wizard.channels.statusConfigured"), - unconfiguredLabel: t("wizard.channels.statusNeedsSetup"), - configuredHint: t("wizard.channels.statusSignalCliFound"), - unconfiguredHint: t("wizard.channels.statusSignalCliMissing"), - configuredScore: 1, - unconfiguredScore: 0, - resolveConfigured: ({ cfg, accountId }) => - accountId - ? resolveSignalAccount({ cfg, accountId }).configured - : listSignalAccountIds(cfg).some( - (resolvedAccountId) => - resolveSignalAccount({ cfg, accountId: resolvedAccountId }).configured, - ), - resolveBinaryPath: ({ cfg, accountId }) => - resolveSignalAccount({ cfg, accountId }).config.cliPath ?? "signal-cli", - detectBinary, - }), + status: { + ...managedStatus, + resolveStatusLines: async (params) => { + if (resolveSignalAccount(params).transport.kind === "managed-native") { + return (await managedStatus.resolveStatusLines?.(params)) ?? []; + } + return [`Signal: ${params.configured ? configuredLabel : unconfiguredLabel}`]; + }, + resolveSelectionHint: async (params) => { + if (resolveSignalAccount(params).transport.kind === "managed-native") { + return await managedStatus.resolveSelectionHint?.(params); + } + return params.configured ? configuredLabel : unconfiguredLabel; + }, + resolveQuickstartScore: async (params) => { + if (resolveSignalAccount(params).transport.kind === "managed-native") { + return await managedStatus.resolveQuickstartScore?.(params); + } + return params.configured ? 1 : 0; + }, + }, prepare: async ({ cfg, accountId, credentialValues, runtime, prompter, options }) => { if (!options?.allowSignalInstall) { return undefined; } + const transport = resolveSignalAccount({ cfg, accountId }).transport; + if (transport.kind !== "managed-native") { + return undefined; + } const currentCliPath = (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? - resolveSignalAccount({ cfg, accountId }).config.cliPath ?? + (transport.kind === "managed-native" ? transport.cliPath : undefined) ?? "signal-cli"; const cliDetected = await detectBinary(currentCliPath); const wantsInstall = await prompter.confirm({ @@ -77,7 +107,10 @@ export const signalSetupWizard: ChannelSetupWizard = { }, credentials: [], textInputs: [ - createSignalCliPathTextInput(async ({ currentValue }) => { + createSignalCliPathTextInput(async ({ cfg, accountId, currentValue }) => { + if (resolveSignalAccount({ cfg, accountId }).transport.kind !== "managed-native") { + return false; + } return !(await detectBinary(currentValue ?? "signal-cli")); }), signalNumberTextInput, diff --git a/extensions/signal/src/setup-transport.test.ts b/extensions/signal/src/setup-transport.test.ts new file mode 100644 index 000000000000..2df13403d62e --- /dev/null +++ b/extensions/signal/src/setup-transport.test.ts @@ -0,0 +1,801 @@ +// Signal tests cover the setup-facing transport contract. +import { describe, expect, it, vi } from "vitest"; +import { resolveSignalAccount } from "./accounts.js"; +import { + detectSignalTransport, + prepareSignalManagedNativeTransport, + probeSignalTransport, + writeSignalAccountTransport, +} from "./setup-transport.js"; + +describe("detectSignalTransport", () => { + it("prefers native deterministically when both endpoints are healthy", async () => { + const transport = await detectSignalTransport({ + url: "http://signal:8080", + probeNative: vi.fn().mockResolvedValue({ ok: true }), + probeContainer: vi.fn().mockResolvedValue({ ok: true }), + }); + + expect(transport).toEqual({ kind: "external-native", url: "http://signal:8080" }); + }); + + it("selects container only when its endpoint is healthy", async () => { + const probeContainer = vi.fn().mockResolvedValue({ ok: true }); + const transport = await detectSignalTransport({ + url: "http://signal:8080/", + account: "+15555550123", + probeNative: vi.fn().mockResolvedValue({ ok: false }), + probeContainer, + }); + + expect(transport).toEqual({ kind: "container", url: "http://signal:8080" }); + expect(probeContainer).toHaveBeenCalledWith("http://signal:8080", 10_000, "+15555550123"); + }); + + it("rejects an endpoint that matches neither transport", async () => { + await expect( + detectSignalTransport({ + url: "http://signal:8080", + probeNative: vi.fn().mockResolvedValue({ ok: false }), + probeContainer: vi.fn().mockResolvedValue({ ok: false }), + }), + ).rejects.toThrow("Signal transport not reachable at http://signal:8080"); + }); +}); + +describe("prepareSignalManagedNativeTransport", () => { + it("normalizes the default account id before preserving its managed transport", () => { + const cfg = { + channels: { + signal: { + transport: { + kind: "managed-native", + url: "https://127.0.0.1:9443", + cliPath: "/opt/signal-cli", + configPath: "/var/lib/signal-cli", + httpHost: "127.0.0.2", + httpPort: 8181, + startupTimeoutMs: 45_000, + }, + }, + }, + } as const; + + expect( + prepareSignalManagedNativeTransport({ cfg: cfg as never, accountId: " Default " }), + ).toEqual({ + kind: "managed-native", + url: "https://127.0.0.1:9443", + cliPath: "/opt/signal-cli", + configPath: "/var/lib/signal-cli", + httpHost: "127.0.0.2", + httpPort: 8181, + startupTimeoutMs: 45_000, + }); + }); + + it("allocates distinct ports for managed native accounts", () => { + const cfg = { + channels: { + signal: { + account: "+15555550123", + transport: { kind: "managed-native", httpPort: 8080 }, + accounts: { work: { account: "+15555550124" } }, + }, + }, + } as const; + + expect(prepareSignalManagedNativeTransport({ cfg: cfg as never, accountId: "work" })).toEqual({ + kind: "managed-native", + httpHost: "127.0.0.1", + httpPort: 8081, + }); + }); + + it("rejects an explicitly requested port owned by another account", () => { + const cfg = { + channels: { + signal: { + account: "+15555550123", + transport: { kind: "managed-native", httpPort: 8080 }, + accounts: { work: { account: "+15555550124" } }, + }, + }, + } as const; + + expect(() => + prepareSignalManagedNativeTransport({ + cfg: cfg as never, + accountId: "work", + overrides: { httpPort: 8080 }, + }), + ).toThrow("Signal managed native port 8080 is already reserved"); + }); + + it("preserves an existing implicit port when adding a lexically earlier account", () => { + const cfg = { + channels: { + signal: { + accounts: { + work: { account: "+15555550124", transport: { kind: "managed-native" } }, + }, + }, + }, + } as const; + + expect(resolveSignalAccount({ cfg: cfg as never, accountId: "work" }).transport).toMatchObject({ + httpPort: 8080, + }); + const transport = prepareSignalManagedNativeTransport({ + cfg: cfg as never, + accountId: "personal", + }); + const next = writeSignalAccountTransport({ + cfg: cfg as never, + accountId: "personal", + transport, + }); + + expect(transport.httpPort).toBe(8081); + expect(resolveSignalAccount({ cfg: next, accountId: "work" }).transport).toMatchObject({ + httpPort: 8080, + }); + }); + + it("keeps sibling implicit allocation stable while updating an existing target", () => { + const cfg = { + channels: { + signal: { + accounts: { + a: { + account: "+15555550123", + transport: { kind: "managed-native", httpPort: 8080 }, + }, + b: { account: "+15555550124", transport: { kind: "managed-native" } }, + }, + }, + }, + } as const; + + const transport = prepareSignalManagedNativeTransport({ + cfg: cfg as never, + accountId: "a", + overrides: { cliPath: "/opt/signal-cli" }, + }); + const next = writeSignalAccountTransport({ + cfg: cfg as never, + accountId: "a", + transport, + }); + + expect(transport.httpPort).toBe(8080); + expect(resolveSignalAccount({ cfg: next, accountId: "b" }).transport).toMatchObject({ + httpPort: 8081, + }); + }); + + it("preserves managed options behind a case-preserving account key", () => { + const cfg = { + channels: { + signal: { + accounts: { + Ops: { + account: "+15555550124", + transport: { + kind: "managed-native", + cliPath: "/opt/signal-cli", + httpPort: 8181, + }, + }, + }, + }, + }, + } as const; + + expect(prepareSignalManagedNativeTransport({ cfg: cfg as never, accountId: "ops" })).toEqual({ + kind: "managed-native", + cliPath: "/opt/signal-cli", + httpHost: "127.0.0.1", + httpPort: 8181, + }); + }); + + it("reserves a configured default account when setup will re-enable the channel", () => { + const cfg = { + channels: { + signal: { + enabled: false, + account: "+15555550123", + transport: { kind: "managed-native", httpPort: 8080 }, + accounts: { work: { account: "+15555550124" } }, + }, + }, + } as const; + + expect( + prepareSignalManagedNativeTransport({ cfg: cfg as never, accountId: "work" }).httpPort, + ).toBe(8081); + }); + + it("reserves ports owned by disabled named accounts", () => { + const cfg = { + channels: { + signal: { + accounts: { + dormant: { + enabled: false, + account: "+15555550123", + transport: { kind: "managed-native", httpPort: 8080 }, + }, + work: { account: "+15555550124" }, + }, + }, + }, + } as const; + + expect( + prepareSignalManagedNativeTransport({ cfg: cfg as never, accountId: "work" }).httpPort, + ).toBe(8081); + }); + + it("preserves a selected account's collision-free managed port and options", () => { + const cfg = { + channels: { + signal: { + account: "+15555550123", + transport: { kind: "managed-native", httpPort: 8080 }, + accounts: { + work: { + account: "+15555550124", + transport: { + kind: "managed-native", + httpHost: "0.0.0.0", + httpPort: 19089, + cliPath: "/opt/signal-cli", + }, + }, + }, + }, + }, + } as const; + + expect(prepareSignalManagedNativeTransport({ cfg: cfg as never, accountId: "work" })).toEqual({ + kind: "managed-native", + httpHost: "0.0.0.0", + httpPort: 19089, + cliPath: "/opt/signal-cli", + }); + }); + + it("keeps an aligned managed connection URL on the allocated bind port", () => { + const cfg = { + channels: { + signal: { + account: "+15555550123", + transport: { kind: "managed-native", httpPort: 8080 }, + accounts: { + work: { + account: "+15555550124", + transport: { + kind: "managed-native", + url: "http://127.0.0.1:8080", + httpHost: "0.0.0.0", + }, + }, + }, + }, + }, + } as const; + + expect(prepareSignalManagedNativeTransport({ cfg: cfg as never, accountId: "work" })).toEqual({ + kind: "managed-native", + url: "http://127.0.0.1:8081", + httpHost: "0.0.0.0", + httpPort: 8081, + }); + }); + + it("keeps an existing managed connection URL aligned with bind overrides", () => { + const cfg = { + channels: { + signal: { + account: "+15555550123", + transport: { + kind: "managed-native", + url: "http://127.0.0.2:8080", + httpHost: "127.0.0.2", + httpPort: 8080, + }, + }, + }, + } as const; + + expect( + prepareSignalManagedNativeTransport({ + cfg: cfg as never, + accountId: "default", + overrides: { httpHost: "127.0.0.3", httpPort: 8181 }, + }), + ).toEqual({ + kind: "managed-native", + url: "http://127.0.0.3:8181", + httpHost: "127.0.0.3", + httpPort: 8181, + }); + }); + + it("keeps an exact LAN connection URL aligned with bind port changes", () => { + const cfg = { + channels: { + signal: { + account: "+15555550123", + transport: { + kind: "managed-native", + url: "http://192.168.1.2:8181", + httpHost: "192.168.1.2", + httpPort: 8181, + }, + }, + }, + } as const; + + expect( + prepareSignalManagedNativeTransport({ + cfg: cfg as never, + accountId: "default", + overrides: { httpPort: 8282 }, + }), + ).toEqual({ + kind: "managed-native", + url: "http://192.168.1.2:8282", + httpHost: "192.168.1.2", + httpPort: 8282, + }); + }); + + it("reserves a local HTTPS proxy endpoint independently from the daemon bind", () => { + const cfg = { + channels: { + signal: { + transport: { + kind: "managed-native", + url: "https://127.0.0.1:8080", + }, + }, + }, + } as const; + + expect( + prepareSignalManagedNativeTransport({ cfg: cfg as never, accountId: "default" }), + ).toEqual({ + kind: "managed-native", + url: "https://127.0.0.1:8080", + httpHost: "127.0.0.1", + httpPort: 8081, + }); + }); + + it("uses IPv6 loopback when an aligned endpoint moves to the IPv6 wildcard bind", () => { + const cfg = { + channels: { + signal: { + transport: { + kind: "managed-native", + url: "http://127.0.0.1:8080", + httpHost: "127.0.0.1", + httpPort: 8080, + }, + }, + }, + } as const; + + expect( + prepareSignalManagedNativeTransport({ + cfg: cfg as never, + accountId: "default", + overrides: { httpHost: "::" }, + }), + ).toEqual({ + kind: "managed-native", + url: "http://[::1]:8080", + httpHost: "::", + httpPort: 8080, + }); + }); + + it("reserves ports used by enabled local external transports", () => { + const cfg = { + channels: { + signal: { + accounts: { + external: { + account: "+15555550123", + transport: { kind: "external-native", url: "http://localhost:8080" }, + }, + work: { account: "+15555550124" }, + }, + }, + }, + } as const; + + expect( + prepareSignalManagedNativeTransport({ cfg: cfg as never, accountId: "work" }).httpPort, + ).toBe(8081); + }); + + it.each(["http://localhost.:8080", "http://[::ffff:127.0.0.1]:8080"])( + "reserves port 8080 for loopback alias %s", + (url) => { + const cfg = { + channels: { + signal: { + accounts: { + external: { + account: "+15555550123", + transport: { kind: "external-native", url }, + }, + work: { account: "+15555550124" }, + }, + }, + }, + } as const; + + expect( + prepareSignalManagedNativeTransport({ cfg: cfg as never, accountId: "work" }).httpPort, + ).toBe(8081); + }, + ); + + it("reserves managed bind and local connection endpoint ports", () => { + const cfg = { + channels: { + signal: { + accounts: { + proxy: { + account: "+15555550123", + transport: { + kind: "managed-native", + url: "http://localhost:8080", + httpPort: 8181, + }, + }, + work: { account: "+15555550124" }, + }, + }, + }, + } as const; + + expect( + prepareSignalManagedNativeTransport({ cfg: cfg as never, accountId: "work" }).httpPort, + ).toBe(8081); + }); + + it.each([0, Number.NaN, 65_536])("rejects invalid preferred port %s", (httpPort) => { + expect(() => + prepareSignalManagedNativeTransport({ + cfg: {}, + accountId: "work", + overrides: { httpPort }, + }), + ).toThrow("Signal managed native port must be an integer between 1 and 65535."); + }); +}); + +describe("probeSignalTransport", () => { + it("probes only the configured concrete transport", async () => { + const probeNative = vi.fn().mockResolvedValue({ ok: true }); + const probeContainer = vi.fn().mockResolvedValue({ ok: true }); + + await expect( + probeSignalTransport({ + cfg: {}, + accountId: "default", + transport: { kind: "container", url: "http://signal:8080" }, + account: "+15555550123", + probeNative, + probeContainer, + }), + ).resolves.toEqual({ ok: true }); + expect(probeNative).not.toHaveBeenCalled(); + expect(probeContainer).toHaveBeenCalledWith("http://signal:8080", 10_000, "+15555550123"); + }); + + it("probes the allocated port for an implicit managed account transport", async () => { + const probeNative = vi.fn().mockResolvedValue({ ok: true }); + const cfg = { + channels: { + signal: { + account: "+15555550123", + transport: { kind: "managed-native", httpPort: 8080 }, + accounts: { work: { account: "+15555550124" } }, + }, + }, + } as const; + + await expect( + probeSignalTransport({ + cfg: cfg as never, + accountId: "work", + transport: { kind: "managed-native" }, + probeNative, + }), + ).resolves.toEqual({ ok: true }); + expect(probeNative).toHaveBeenCalledWith("http://127.0.0.1:8081", 10_000); + }); +}); + +describe("writeSignalAccountTransport", () => { + it("ignores the unconfigured implicit default account during endpoint collision checks", () => { + const next = writeSignalAccountTransport({ + cfg: {}, + accountId: "work", + transport: { kind: "external-native", url: "http://localhost:8080" }, + }); + + expect(next.channels?.signal?.accounts?.work?.transport).toEqual({ + kind: "external-native", + url: "http://localhost:8080", + }); + }); + + it("rejects explicit non-HTTP endpoint schemes", () => { + expect(() => + writeSignalAccountTransport({ + cfg: {}, + accountId: "default", + transport: { kind: "external-native", url: "ftp://signal.example" }, + }), + ).toThrow("Signal transport URL unsupported protocol: ftp:"); + }); + + it.each(["http:/localhost:8080", "http:localhost:8080", "http:///localhost:8080"])( + "rejects malformed HTTP endpoint %s", + (url) => { + expect(() => + writeSignalAccountTransport({ + cfg: {}, + accountId: "default", + transport: { kind: "external-native", url }, + }), + ).toThrow("Signal transport URL has a malformed HTTP scheme"); + }, + ); + + it("rejects a local external endpoint on an enabled sibling managed bind", () => { + expect(() => + writeSignalAccountTransport({ + cfg: { + channels: { + signal: { + enabled: false, + account: "+15555550123", + transport: { kind: "managed-native", httpPort: 8181 }, + }, + }, + }, + accountId: "work", + transport: { kind: "external-native", url: "http://localhost:8181" }, + }), + ).toThrow( + 'Signal external-native account "work" uses local port 8181, which conflicts with managed native account "default".', + ); + }); + + it("rejects a local external endpoint on a sibling's implicit managed bind", () => { + expect(() => + writeSignalAccountTransport({ + cfg: { + channels: { + signal: { + accounts: { + work: { + account: "+15555550123", + transport: { kind: "managed-native" }, + }, + }, + }, + }, + }, + accountId: "personal", + transport: { kind: "external-native", url: "http://localhost:8080" }, + }), + ).toThrow( + 'Signal external-native account "personal" uses local port 8080, which conflicts with managed native account "work".', + ); + }); + + it("allows a local external endpoint on a disabled sibling managed bind", () => { + const next = writeSignalAccountTransport({ + cfg: { + channels: { + signal: { + account: "+15555550124", + accounts: { + dormant: { + enabled: false, + account: "+15555550123", + transport: { kind: "managed-native", httpPort: 8181 }, + }, + }, + }, + }, + }, + accountId: "work", + transport: { kind: "container", url: "http://localhost:8181" }, + }); + + expect(next.channels?.signal?.accounts?.work?.transport).toEqual({ + kind: "container", + url: "http://localhost:8181", + }); + }); + + it("writes the implicit default account without changing named accounts", () => { + const next = writeSignalAccountTransport({ + cfg: { + channels: { + signal: { + dmPolicy: "allowlist", + apiMode: "native", + httpUrl: "http://legacy-native:8080", + autoStart: false, + accounts: { work: { account: "+15555550123" } }, + }, + }, + } as never, + accountId: "default", + transport: { kind: "external-native", url: "http://native:8080" }, + }); + + expect(next.channels?.signal?.transport).toEqual({ + kind: "external-native", + url: "http://native:8080", + }); + expect(next.channels?.signal?.accounts?.work).toEqual({ account: "+15555550123" }); + expect(next.channels?.signal?.dmPolicy).toBe("allowlist"); + expect(next.channels?.signal).not.toHaveProperty("apiMode"); + expect(next.channels?.signal).not.toHaveProperty("httpUrl"); + expect(next.channels?.signal).not.toHaveProperty("autoStart"); + }); + + it("makes the canonical root authoritative over a nested default transport", () => { + const next = writeSignalAccountTransport({ + cfg: { + channels: { + signal: { + accounts: { + default: { + account: "+15555550123", + transport: { kind: "managed-native", httpPort: 8181 }, + }, + }, + }, + }, + } as never, + accountId: "default", + transport: { kind: "external-native", url: "http://native:8080" }, + }); + + expect(next.channels?.signal?.transport).toEqual({ + kind: "external-native", + url: "http://native:8080", + }); + expect(next.channels?.signal?.accounts?.default).not.toHaveProperty("transport"); + }); + + it("writes only the selected named account", () => { + const next = writeSignalAccountTransport({ + cfg: { + channels: { + signal: { + account: "+15555550123", + transport: { kind: "managed-native" }, + }, + }, + }, + accountId: "work", + transport: { kind: "container", url: "http://container:8080/" }, + }); + + expect(next.channels?.signal?.transport).toEqual({ kind: "managed-native" }); + expect(next.channels?.signal?.accounts?.work?.transport).toEqual({ + kind: "container", + url: "http://container:8080", + }); + }); + + it("rejects an enabled container transport without an effective account", () => { + expect(() => + writeSignalAccountTransport({ + cfg: {}, + accountId: "work", + transport: { kind: "container", url: "http://container:8080" }, + }), + ).toThrow("Signal container transport requires an account number for an enabled account."); + }); + + it("allows an account-less container transport for a disabled target", () => { + const next = writeSignalAccountTransport({ + cfg: { + channels: { + signal: { + accounts: { work: { enabled: false } }, + }, + }, + }, + accountId: "work", + transport: { kind: "container", url: "http://container:8080" }, + }); + + expect(next.channels?.signal?.accounts?.work?.transport).toEqual({ + kind: "container", + url: "http://container:8080", + }); + }); + + it("rejects invalid managed native hosts", () => { + expect(() => + writeSignalAccountTransport({ + cfg: {}, + accountId: "default", + transport: { kind: "managed-native", httpHost: "bad host" }, + }), + ).toThrow("Signal transport host must be a hostname or IP address"); + }); + + it.each(["localhost:8181", "bad:host"])( + "rejects a managed native host containing a non-IPv6 colon: %s", + (httpHost) => { + expect(() => + writeSignalAccountTransport({ + cfg: {}, + accountId: "default", + transport: { kind: "managed-native", httpHost }, + }), + ).toThrow("Signal transport host must be a hostname or IP address"); + }, + ); + + it("rejects a managed bind on a sibling local endpoint", () => { + expect(() => + writeSignalAccountTransport({ + cfg: { + channels: { + signal: { + account: "+15555550123", + accounts: { + external: { + transport: { kind: "external-native", url: "http://localhost:8181" }, + }, + }, + }, + }, + }, + accountId: "managed", + transport: { kind: "managed-native", httpPort: 8181 }, + }), + ).toThrow( + 'Signal managed native account "managed" binds port 8181, which conflicts with account "external" local transport endpoint.', + ); + }); + + it("normalizes a managed native connection URL", () => { + const next = writeSignalAccountTransport({ + cfg: { channels: { signal: {} } }, + accountId: "default", + transport: { + kind: "managed-native", + url: "127.0.0.1:8181/", + httpHost: "0.0.0.0", + httpPort: 8181, + }, + }); + + expect(next.channels?.signal?.transport).toEqual({ + kind: "managed-native", + url: "http://127.0.0.1:8181", + httpHost: "0.0.0.0", + httpPort: 8181, + }); + }); +}); diff --git a/extensions/signal/src/setup-transport.ts b/extensions/signal/src/setup-transport.ts new file mode 100644 index 000000000000..4bed455e2170 --- /dev/null +++ b/extensions/signal/src/setup-transport.ts @@ -0,0 +1,322 @@ +// Signal setup owns transport discovery and canonical account writes. +import { normalizeAccountId, resolveAccountEntry } from "openclaw/plugin-sdk/account-resolution"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { + DEFAULT_ACCOUNT_ID, + patchChannelConfigForAccount, +} from "openclaw/plugin-sdk/setup-runtime"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import type { SignalTransportConfig } from "./account-types.js"; +import { + listSignalAccountIds, + resolveSignalAccount, + resolveSignalAccountConfig, + resolveSignalTransport, +} from "./accounts.js"; +import { clearLegacySignalTransportFieldsForAccount } from "./config-compat.js"; +import type { + SignalContainerTransportProbe, + SignalNativeTransportProbe, + SignalTransportProbeResult, +} from "./transport-detection.js"; +import { + allocateSignalManagedNativePort, + assignSignalManagedNativePort, + DEFAULT_SIGNAL_MANAGED_NATIVE_HOST, + isSignalManagedNativeConnectionUrlForBind, + resolveLocalSignalTransportPort, +} from "./transport-policy.js"; +import { normalizeSignalTransportHost, normalizeSignalTransportUrl } from "./transport-url.js"; + +export { detectSignalTransport, type SignalTransportProbeResult } from "./transport-detection.js"; + +export type SignalManagedNativeTransport = Extract< + SignalTransportConfig, + { kind: "managed-native" } +>; + +function managedTransportOptions( + transport: SignalManagedNativeTransport, +): Omit { + const { kind: _kind, ...options } = transport; + return options; +} + +function normalizeTransport(transport: SignalTransportConfig): SignalTransportConfig { + if (transport.kind === "managed-native") { + return { + ...transport, + ...(transport.url ? { url: normalizeSignalTransportUrl(transport.url) } : {}), + ...(transport.httpHost ? { httpHost: normalizeSignalTransportHost(transport.httpHost) } : {}), + }; + } + return { ...transport, url: normalizeSignalTransportUrl(transport.url) }; +} + +function assertSignalContainerTransportHasAccount(params: { + cfg: OpenClawConfig; + accountId: string; + transport: SignalTransportConfig; +}): void { + if (params.transport.kind !== "container" || params.cfg.channels?.signal?.enabled === false) { + return; + } + const account = resolveSignalAccountConfig(params.cfg, normalizeAccountId(params.accountId)); + if (account.enabled === false || normalizeOptionalString(account.account)) { + return; + } + throw new Error("Signal container transport requires an account number for an enabled account."); +} + +function assertSignalLocalEndpointDoesNotConflictWithManagedSibling(params: { + cfg: OpenClawConfig; + accountId: string; + transport: SignalTransportConfig; +}): void { + if (params.transport.kind === "managed-native") { + return; + } + const localPort = resolveLocalSignalTransportPort(params.transport.url); + if (localPort === undefined) { + return; + } + const targetAccountId = normalizeAccountId(params.accountId); + for (const accountId of listSignalAccountIds(params.cfg)) { + if (normalizeAccountId(accountId) === targetAccountId) { + continue; + } + const accountEntry = resolveAccountEntry(params.cfg.channels?.signal?.accounts, accountId); + if (accountEntry?.enabled === false) { + continue; + } + const siblingAccount = resolveSignalAccount({ + cfg: params.cfg, + accountId, + }); + // Empty configs expose an implicit default placeholder with managed defaults. Until that + // account is configured, its synthetic endpoint does not reserve a port. + if (!siblingAccount.configured) { + continue; + } + const siblingTransport = siblingAccount.transport; + if (siblingTransport?.kind !== "managed-native" || siblingTransport.httpPort !== localPort) { + continue; + } + throw new Error( + `Signal ${params.transport.kind} account "${targetAccountId}" uses local port ${localPort}, which conflicts with managed native account "${accountId}". Choose a distinct transport URL.`, + ); + } +} + +export function resolveConfiguredSignalTransport( + cfg: OpenClawConfig, + accountId: string, +): SignalTransportConfig | undefined { + const signal = cfg.channels?.signal; + const normalizedAccountId = normalizeAccountId(accountId); + return normalizedAccountId === DEFAULT_ACCOUNT_ID + ? (signal?.transport ?? resolveAccountEntry(signal?.accounts, normalizedAccountId)?.transport) + : resolveAccountEntry(signal?.accounts, normalizedAccountId)?.transport; +} + +function alignManagedConnectionUrlAfterBindChange(params: { + existing: SignalManagedNativeTransport | undefined; + prepared: SignalManagedNativeTransport; + httpPort: number; + hasUrlOverride: boolean; +}): SignalManagedNativeTransport { + if ( + params.hasUrlOverride || + !params.existing?.url || + !isSignalManagedNativeConnectionUrlForBind(params.existing) + ) { + return assignSignalManagedNativePort(params.prepared, params.httpPort); + } + + const connectionUrl = new URL(params.existing.url); + connectionUrl.port = String(params.httpPort); + const alignedPortUrl = normalizeSignalTransportUrl(connectionUrl.toString()); + const next = { ...params.prepared, url: alignedPortUrl, httpPort: params.httpPort }; + if (isSignalManagedNativeConnectionUrlForBind(next)) { + return next; + } + + const bindHost = params.prepared.httpHost ?? DEFAULT_SIGNAL_MANAGED_NATIVE_HOST; + const connectionHost = + bindHost === "0.0.0.0" ? "127.0.0.1" : bindHost === "::" ? "::1" : bindHost; + connectionUrl.hostname = connectionHost.includes(":") ? `[${connectionHost}]` : connectionHost; + return { + ...next, + url: normalizeSignalTransportUrl(connectionUrl.toString()), + }; +} + +export function prepareSignalManagedNativeTransport(params: { + cfg: OpenClawConfig; + accountId: string; + overrides?: Omit; +}): SignalManagedNativeTransport { + const existing = resolveConfiguredSignalTransport(params.cfg, params.accountId); + const existingManaged = existing?.kind === "managed-native" ? existing : undefined; + const preferredPort = params.overrides?.httpPort ?? existingManaged?.httpPort; + const prepared: SignalManagedNativeTransport = { + kind: "managed-native", + ...existingManaged, + ...params.overrides, + httpHost: + params.overrides?.httpHost ?? existingManaged?.httpHost ?? DEFAULT_SIGNAL_MANAGED_NATIVE_HOST, + }; + const portsByAccountId = new Map>(); + const implicitManagedAccountIds: string[] = []; + // Resolve the full current allocation before excluding the selected owner. Otherwise an + // implicit sibling can claim the target's port and make an unrelated edit swap both daemons. + for (const accountId of listSignalAccountIds(params.cfg)) { + const normalizedAccountId = normalizeAccountId(accountId); + const accountConfig = resolveSignalAccountConfig(params.cfg, accountId); + if (!normalizeOptionalString(accountConfig.account) && !accountConfig.transport) { + continue; + } + const accountPorts = portsByAccountId.get(normalizedAccountId) ?? new Set(); + portsByAccountId.set(normalizedAccountId, accountPorts); + const transport = accountConfig.transport; + if (transport?.kind === "managed-native") { + if (transport.httpPort !== undefined) { + accountPorts.add(transport.httpPort); + } else { + implicitManagedAccountIds.push(normalizedAccountId); + } + if (transport.url && !isSignalManagedNativeConnectionUrlForBind(transport)) { + const localConnectionPort = resolveLocalSignalTransportPort(transport.url); + if (localConnectionPort !== undefined) { + accountPorts.add(localConnectionPort); + } + } + continue; + } + if (transport?.kind === "external-native" || transport?.kind === "container") { + const localPort = resolveLocalSignalTransportPort(transport.url); + if (localPort !== undefined) { + accountPorts.add(localPort); + } + continue; + } + implicitManagedAccountIds.push(normalizedAccountId); + } + const currentReservedPorts = new Set(); + for (const accountPorts of portsByAccountId.values()) { + for (const httpPort of accountPorts) { + currentReservedPorts.add(httpPort); + } + } + for (const accountId of implicitManagedAccountIds) { + const accountPorts = portsByAccountId.get(accountId); + if (!accountPorts) { + continue; + } + const httpPort = allocateSignalManagedNativePort({ reservedPorts: currentReservedPorts }); + currentReservedPorts.add(httpPort); + accountPorts.add(httpPort); + } + const targetAccountId = normalizeAccountId(params.accountId); + const reservedPorts = new Set(); + for (const [accountId, accountPorts] of portsByAccountId) { + if (accountId === targetAccountId) { + continue; + } + for (const httpPort of accountPorts) { + reservedPorts.add(httpPort); + } + } + + const hasIndependentPreparedConnectionUrl = + prepared.url && + (params.overrides?.url !== undefined + ? !isSignalManagedNativeConnectionUrlForBind(prepared) + : Boolean( + existingManaged?.url && !isSignalManagedNativeConnectionUrlForBind(existingManaged), + )); + if (hasIndependentPreparedConnectionUrl && prepared.url) { + const localConnectionPort = resolveLocalSignalTransportPort(prepared.url); + if (localConnectionPort !== undefined) { + reservedPorts.add(localConnectionPort); + } + } + + if (params.overrides?.httpPort !== undefined && reservedPorts.has(params.overrides.httpPort)) { + throw new Error( + `Signal managed native port ${params.overrides.httpPort} is already reserved by another account or local transport endpoint.`, + ); + } + const httpPort = allocateSignalManagedNativePort({ reservedPorts, preferredPort }); + // A managed connection URL that points at the daemon's bind is one endpoint. + // Keep its connection endpoint aligned when setup changes or reallocates the bind. + return alignManagedConnectionUrlAfterBindChange({ + existing: existingManaged, + prepared, + httpPort, + hasUrlOverride: params.overrides?.url !== undefined, + }); +} + +export async function probeSignalTransport(params: { + cfg: OpenClawConfig; + accountId: string; + transport: SignalTransportConfig; + account?: string; + timeoutMs?: number; + probeNative?: SignalNativeTransportProbe; + probeContainer?: SignalContainerTransportProbe; +}): Promise { + const timeoutMs = params.timeoutMs ?? 10_000; + const transport = + params.transport.kind === "managed-native" + ? prepareSignalManagedNativeTransport({ + cfg: params.cfg, + accountId: params.accountId, + overrides: managedTransportOptions(params.transport), + }) + : params.transport; + const resolved = resolveSignalTransport(transport); + if (resolved.kind === "container") { + const probeContainer = + params.probeContainer ?? (await import("./transport-probes.runtime.js")).containerCheck; + return probeContainer(resolved.baseUrl, timeoutMs, params.account); + } + const probeNative = + params.probeNative ?? (await import("./transport-probes.runtime.js")).nativeCheck; + return probeNative(resolved.baseUrl, timeoutMs); +} + +export function writeSignalAccountTransport(params: { + cfg: OpenClawConfig; + accountId: string; + transport: SignalTransportConfig; +}): OpenClawConfig { + const transport = normalizeTransport(params.transport); + assertSignalContainerTransportHasAccount({ + cfg: params.cfg, + accountId: params.accountId, + transport, + }); + assertSignalLocalEndpointDoesNotConflictWithManagedSibling({ + cfg: params.cfg, + accountId: params.accountId, + transport, + }); + const next = patchChannelConfigForAccount({ + cfg: params.cfg, + channel: "signal", + accountId: params.accountId, + patch: { transport }, + }); + const canonical = clearLegacySignalTransportFieldsForAccount({ + cfg: next, + accountId: params.accountId, + }); + if (transport.kind === "managed-native") { + // Direct setup consumers can bypass prepareSignalManagedNativeTransport. Resolve the + // candidate before returning so a sibling endpoint collision is never persisted. + resolveSignalAccount({ cfg: canonical, accountId: params.accountId }); + } + return canonical; +} diff --git a/extensions/signal/src/shared.ts b/extensions/signal/src/shared.ts index 315fd1b5b272..8b960f9de377 100644 --- a/extensions/signal/src/shared.ts +++ b/extensions/signal/src/shared.ts @@ -17,6 +17,7 @@ import { } from "./accounts.js"; import { resolveSignalTarget } from "./aliases.js"; import { SignalChannelConfigSchema } from "./config-schema.js"; +import { signalDoctor } from "./doctor.js"; import { createSignalSetupWizardProxy } from "./setup-core.js"; const SIGNAL_CHANNEL = "signal" as const; @@ -34,7 +35,7 @@ const signalConfigAdapterBase = createScopedChannelConfigAdapter listSignalAccountIds(cfg), resolveAccount: adaptScopedAccountAccessor((params) => resolveSignalAccount(params)), defaultAccountId: (cfg) => resolveDefaultSignalAccountId(cfg), - clearBaseFields: ["account", "configPath", "httpUrl", "httpHost", "httpPort", "cliPath", "name"], + clearBaseFields: ["account", "accountUuid", "transport", "name"], resolveAllowFrom: (account: ResolvedSignalAccount) => account.config.allowFrom, formatAllowFrom: (allowFrom) => normalizeStringifiedEntries(allowFrom) @@ -80,7 +81,7 @@ export const signalSecurityAdapter = createRestrictSendersChannelSecurity["setupWizard"]>; - setup: NonNullable["setup"]>; + setupContract: NonNullable["setupContract"]>; }): Pick< ChannelPlugin, | "id" @@ -92,10 +93,11 @@ export function createSignalPluginBase(params: { | "configSchema" | "config" | "security" - | "setup" + | "setupContract" | "messaging" + | "doctor" > { - const base = createChannelPluginBase({ + const base = createChannelPluginBase({ id: SIGNAL_CHANNEL, meta: { ...getChatChannelMeta(SIGNAL_CHANNEL), @@ -111,6 +113,7 @@ export function createSignalPluginBase(params: { }, reload: { configPrefixes: ["channels.signal"] }, configSchema: SignalChannelConfigSchema, + doctor: signalDoctor, config: { ...signalConfigAdapter, isConfigured: (account) => account.configured, @@ -124,7 +127,7 @@ export function createSignalPluginBase(params: { }), }, security: signalSecurityAdapter, - setup: params.setup, + setupContract: params.setupContract, }); return { ...base, @@ -142,7 +145,8 @@ export function createSignalPluginBase(params: { | "configSchema" | "config" | "security" - | "setup" + | "setupContract" | "messaging" + | "doctor" >; } diff --git a/extensions/signal/src/sse-reconnect.ts b/extensions/signal/src/sse-reconnect.ts index ab14344cf8a8..615a4ab76d9e 100644 --- a/extensions/signal/src/sse-reconnect.ts +++ b/extensions/signal/src/sse-reconnect.ts @@ -7,7 +7,11 @@ import { type BackoffPolicy, type RuntimeEnv, } from "openclaw/plugin-sdk/runtime-env"; -import { type SignalApiMode, type SignalSseEvent, streamSignalEvents } from "./client-adapter.js"; +import { + type SignalSseEvent, + type SignalTransportKind, + streamSignalEvents, +} from "./client-adapter.js"; const DEFAULT_RECONNECT_POLICY: BackoffPolicy = { initialMs: 1_000, @@ -23,7 +27,7 @@ type RunSignalSseLoopParams = { runtime: RuntimeEnv; onEvent: (event: SignalSseEvent) => unknown; timeoutMs?: number; - apiMode?: SignalApiMode; + transportKind?: SignalTransportKind; policy?: Partial; }; @@ -34,7 +38,7 @@ export async function runSignalSseLoop({ runtime, onEvent, timeoutMs, - apiMode, + transportKind, policy, }: RunSignalSseLoopParams) { const reconnectPolicy = { @@ -60,7 +64,7 @@ export async function runSignalSseLoop({ account, abortSignal, timeoutMs, - apiMode, + transportKind, onEvent: async (event: SignalSseEvent) => { reconnectAttempts = 0; await onEvent(event); diff --git a/extensions/signal/src/transport-detection.runtime.ts b/extensions/signal/src/transport-detection.runtime.ts new file mode 100644 index 000000000000..63ec680eb909 --- /dev/null +++ b/extensions/signal/src/transport-detection.runtime.ts @@ -0,0 +1,2 @@ +// Doctor and setup commands lazy-load transport detection through this boundary. +export { detectSignalTransport } from "./transport-detection.js"; diff --git a/extensions/signal/src/transport-detection.ts b/extensions/signal/src/transport-detection.ts new file mode 100644 index 000000000000..f15d71622bd0 --- /dev/null +++ b/extensions/signal/src/transport-detection.ts @@ -0,0 +1,53 @@ +// Signal transport detection is a setup and compatibility-only network probe. +import type { SignalTransportConfig } from "./account-types.js"; +import { normalizeSignalTransportUrl } from "./transport-url.js"; + +const DEFAULT_PROBE_TIMEOUT_MS = 10_000; + +export type SignalTransportProbeResult = { + ok: boolean; + status?: number | null; + error?: string | null; +}; + +export type SignalNativeTransportProbe = ( + url: string, + timeoutMs?: number, +) => Promise; + +export type SignalContainerTransportProbe = ( + url: string, + timeoutMs?: number, + account?: string, +) => Promise; + +export async function detectSignalTransport(params: { + url: string; + account?: string; + timeoutMs?: number; + probeNative?: SignalNativeTransportProbe; + probeContainer?: SignalContainerTransportProbe; +}): Promise { + const url = normalizeSignalTransportUrl(params.url); + const timeoutMs = params.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS; + const probes = + params.probeNative && params.probeContainer + ? undefined + : await import("./transport-probes.runtime.js"); + const probeNative = params.probeNative ?? probes?.nativeCheck; + const probeContainer = params.probeContainer ?? probes?.containerCheck; + if (!probeNative || !probeContainer) { + throw new Error("Signal transport probes are unavailable"); + } + const [native, container] = await Promise.all([ + probeNative(url, timeoutMs).catch(() => ({ ok: false })), + probeContainer(url, timeoutMs, params.account).catch(() => ({ ok: false })), + ]); + if (native.ok) { + return { kind: "external-native", url }; + } + if (container.ok) { + return { kind: "container", url }; + } + throw new Error(`Signal transport not reachable at ${url}`); +} diff --git a/extensions/signal/src/transport-policy.test.ts b/extensions/signal/src/transport-policy.test.ts new file mode 100644 index 000000000000..cab49283cf79 --- /dev/null +++ b/extensions/signal/src/transport-policy.test.ts @@ -0,0 +1,37 @@ +// Guards bind-alignment for managed-native connection URLs: only the ambiguous +// "localhost" name bridges IPv4/IPv6; exact cross-family pairs are different +// sockets whose URLs must survive bind-port reassignment untouched. +import { describe, expect, it } from "vitest"; +import type { SignalTransportConfig } from "./account-types.js"; +import { assignSignalManagedNativePort } from "./transport-policy.js"; + +type SignalManagedNativeTransport = Extract; + +function managedTransport(url: string, httpHost?: string): SignalManagedNativeTransport { + return { + kind: "managed-native", + url, + ...(httpHost ? { httpHost } : {}), + httpPort: 8080, + }; +} + +describe("assignSignalManagedNativePort", () => { + it("rewrites a localhost connection URL aligned with a loopback bind", () => { + const next = assignSignalManagedNativePort( + managedTransport("http://localhost:8080", "127.0.0.1"), + 9090, + ); + expect(next.url).toBe("http://localhost:9090"); + expect(next.httpPort).toBe(9090); + }); + + it("keeps a cross-family loopback URL untouched on bind-port changes", () => { + const next = assignSignalManagedNativePort( + managedTransport("http://[::1]:8080", "127.0.0.1"), + 9090, + ); + expect(next.url).toBe("http://[::1]:8080"); + expect(next.httpPort).toBe(9090); + }); +}); diff --git a/extensions/signal/src/transport-policy.ts b/extensions/signal/src/transport-policy.ts new file mode 100644 index 000000000000..e98285733bf8 --- /dev/null +++ b/extensions/signal/src/transport-policy.ts @@ -0,0 +1,127 @@ +// Signal transport policy centralizes local endpoint collision handling. +import type { SignalTransportConfig } from "./account-types.js"; +import { normalizeSignalTransportUrl } from "./transport-url.js"; + +type SignalManagedNativeTransport = Extract; + +export const DEFAULT_SIGNAL_MANAGED_NATIVE_PORT = 8080; +export const DEFAULT_SIGNAL_MANAGED_NATIVE_HOST = "127.0.0.1"; +const SIGNAL_LOOPBACK_HOST_ALIASES = new Set(["localhost", "127.0.0.1", "::1"]); + +function normalizeSignalEndpointHost(hostname: string): string { + const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, ""); + return normalized.endsWith(".") ? normalized.slice(0, -1) : normalized; +} + +function isSignalLocalEndpointHost(hostname: string): boolean { + return ( + hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname === "::1" || + hostname === "::" || + hostname === "0.0.0.0" || + /^127(?:\.\d{1,3}){3}$/.test(hostname) || + /^::ffff:(?:127(?:\.\d{1,3}){3}|7f[0-9a-f]{2}:[0-9a-f]{1,4})$/.test(hostname) + ); +} + +export function isValidSignalManagedNativePort(value: unknown): value is number { + return Number.isInteger(value) && Number(value) >= 1 && Number(value) <= 65_535; +} + +export function allocateSignalManagedNativePort(params: { + reservedPorts: ReadonlySet; + preferredPort?: number; +}): number { + if (params.preferredPort !== undefined) { + if (!isValidSignalManagedNativePort(params.preferredPort)) { + throw new Error("Signal managed native port must be an integer between 1 and 65535."); + } + if (!params.reservedPorts.has(params.preferredPort)) { + return params.preferredPort; + } + } + let port = DEFAULT_SIGNAL_MANAGED_NATIVE_PORT; + while (port <= 65_535 && params.reservedPorts.has(port)) { + port += 1; + } + if (port > 65_535) { + throw new Error("No available Signal managed native port remains."); + } + return port; +} + +export function resolveLocalSignalTransportPort(baseUrl: string): number | undefined { + try { + const parsed = new URL(baseUrl); + const hostname = normalizeSignalEndpointHost(parsed.hostname); + if (!isSignalLocalEndpointHost(hostname)) { + return undefined; + } + if (parsed.port) { + return Number.parseInt(parsed.port, 10); + } + return parsed.protocol === "https:" ? 443 : 80; + } catch { + return undefined; + } +} + +export function isSignalManagedNativeConnectionUrlForBind( + transport: SignalTransportConfig, +): boolean { + if (transport.kind !== "managed-native" || !transport.url) { + return false; + } + const connectionUrl = new URL(transport.url); + // signal-cli's daemon bind is plain HTTP. A local HTTPS URL is an independent proxy endpoint, + // even when its host and port happen to match the configured daemon bind. + if (connectionUrl.protocol !== "http:") { + return false; + } + const connectionPort = connectionUrl.port ? Number.parseInt(connectionUrl.port, 10) : 80; + const bindPort = transport.httpPort ?? DEFAULT_SIGNAL_MANAGED_NATIVE_PORT; + if (connectionPort !== bindPort) { + return false; + } + const connectionHost = normalizeSignalEndpointHost(connectionUrl.hostname); + const bindHost = normalizeSignalEndpointHost( + transport.httpHost ?? DEFAULT_SIGNAL_MANAGED_NATIVE_HOST, + ); + if (connectionHost === bindHost) { + return true; + } + if (bindHost === "0.0.0.0") { + return connectionHost === "localhost" || /^127(?:\.\d{1,3}){3}$/.test(connectionHost); + } + if (bindHost === "::") { + return connectionHost === "localhost" || connectionHost === "::1"; + } + // Only the ambiguous "localhost" name bridges address families. An exact + // cross-family pair (127.0.0.1 bind vs ::1 URL) is a different socket — e.g. + // an IPv6 proxy — and its URL must not be rewritten on bind-port changes. + return ( + (bindHost === "localhost" && SIGNAL_LOOPBACK_HOST_ALIASES.has(connectionHost)) || + (connectionHost === "localhost" && SIGNAL_LOOPBACK_HOST_ALIASES.has(bindHost)) + ); +} + +export function assignSignalManagedNativePort( + transport: SignalManagedNativeTransport, + httpPort: number, +): SignalManagedNativeTransport { + if (!isValidSignalManagedNativePort(httpPort)) { + throw new Error("Signal managed native port must be an integer between 1 and 65535."); + } + const connectionUrlValue = transport.url; + if (!connectionUrlValue || !isSignalManagedNativeConnectionUrlForBind(transport)) { + return { ...transport, httpPort }; + } + const connectionUrl = new URL(connectionUrlValue); + connectionUrl.port = String(httpPort); + return { + ...transport, + url: normalizeSignalTransportUrl(connectionUrl.toString()), + httpPort, + }; +} diff --git a/extensions/signal/src/transport-probes.runtime.ts b/extensions/signal/src/transport-probes.runtime.ts new file mode 100644 index 000000000000..7f20ff9edbd1 --- /dev/null +++ b/extensions/signal/src/transport-probes.runtime.ts @@ -0,0 +1,3 @@ +// Setup-only transport probes load behind an explicit lazy runtime boundary. +export { containerCheck } from "./client-container.js"; +export { signalCheck as nativeCheck } from "./client.js"; diff --git a/extensions/signal/src/transport-url.ts b/extensions/signal/src/transport-url.ts new file mode 100644 index 000000000000..42835359cc3b --- /dev/null +++ b/extensions/signal/src/transport-url.ts @@ -0,0 +1,57 @@ +// Signal transport URLs are canonicalized before config writes and network use. +import { isIP } from "node:net"; + +export function normalizeSignalTransportUrl(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + throw new Error("Signal transport URL is required"); + } + if (/^https?:/i.test(trimmed) && !/^https?:\/\/[^/]/i.test(trimmed)) { + throw new Error("Signal transport URL has a malformed HTTP scheme"); + } + const explicitScheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(trimmed)?.[1]?.toLowerCase(); + if (explicitScheme && explicitScheme !== "http" && explicitScheme !== "https") { + throw new Error(`Signal transport URL unsupported protocol: ${explicitScheme}:`); + } + const parsed = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`Signal transport URL unsupported protocol: ${parsed.protocol}`); + } + if (parsed.username || parsed.password) { + throw new Error("Signal transport URL must not include credentials"); + } + const pathname = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/+$/, ""); + return `${parsed.protocol}//${parsed.host}${pathname}`; +} + +export function normalizeSignalTransportHost(host: string): string { + const trimmedHost = host.trim(); + const hasOpeningBracket = trimmedHost.startsWith("["); + const hasClosingBracket = trimmedHost.endsWith("]"); + if (hasOpeningBracket !== hasClosingBracket) { + throw new Error("Signal transport host has mismatched IPv6 brackets"); + } + const normalizedHost = hasOpeningBracket ? trimmedHost.slice(1, -1) : trimmedHost; + if (!normalizedHost || /[\s/\\?#@]/.test(normalizedHost)) { + throw new Error("Signal transport host must be a hostname or IP address"); + } + if (isIP(normalizedHost) === 0) { + const hostname = normalizedHost.endsWith(".") ? normalizedHost.slice(0, -1) : normalizedHost; + const labels = hostname.split("."); + if ( + hostname.length > 253 || + labels.some( + (label) => !label || label.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i.test(label), + ) + ) { + throw new Error("Signal transport host must be a hostname or IP address"); + } + } + return normalizedHost; +} + +export function buildSignalTransportHttpUrl(host: string, port: number): string { + const normalizedHost = normalizeSignalTransportHost(host); + const authorityHost = normalizedHost.includes(":") ? `[${normalizedHost}]` : normalizedHost; + return normalizeSignalTransportUrl(`http://${authorityHost}:${port}`); +} diff --git a/extensions/slack/package.json b/extensions/slack/package.json index e0e2faa0f3a0..4ac0db6913d6 100644 --- a/extensions/slack/package.json +++ b/extensions/slack/package.json @@ -60,16 +60,15 @@ "nativeCommandsAutoEnabled": false, "nativeSkillsAutoEnabled": false }, - "cliAddOptions": [ - { - "flags": "--bot-token ", - "description": "Bot token" - }, - { - "flags": "--app-token ", - "description": "App token" - } - ] + "setup": { "fields": [ + { "key": "botToken", "kind": "string", "sensitive": true, "cli": { "flags": "--bot-token ", "description": "Slack bot token" } }, + { "key": "appToken", "kind": "string", "sensitive": true, "cli": { "flags": "--app-token ", "description": "Slack app token" } }, + { "key": "userToken", "kind": "string", "sensitive": true, "cli": { "flags": "--user-token ", "description": "Slack user token" } }, + { "key": "signingSecret", "kind": "string", "sensitive": true, "cli": { "flags": "--signing-secret ", "description": "Slack signing secret" } }, + { "key": "identity", "kind": "choice", "choices": ["bot", "user"], "cli": { "flags": "--identity ", "description": "Slack identity" } }, + { "key": "mode", "kind": "choice", "choices": ["socket", "http"], "cli": { "flags": "--mode ", "description": "Slack connection mode" } }, + { "key": "useEnv", "kind": "boolean", "cli": { "flags": "--use-env", "description": "Use Slack environment credentials" } } + ] } }, "install": { "npmSpec": "@openclaw/slack", diff --git a/extensions/slack/src/channel.setup.ts b/extensions/slack/src/channel.setup.ts index 64817d3407b2..b063775c70f6 100644 --- a/extensions/slack/src/channel.setup.ts +++ b/extensions/slack/src/channel.setup.ts @@ -14,7 +14,11 @@ import { } from "./accounts.js"; import type { ChannelPlugin } from "./channel-api.js"; import { SlackChannelConfigSchema } from "./config-schema.js"; -import { slackSetupAdapter, createSlackSetupWizardProxy } from "./setup-core.js"; +import { + slackSetupAdapter, + slackSetupContract, + createSlackSetupWizardProxy, +} from "./setup-core.js"; import { describeSlackSetupAccount, isSlackSetupAccountConfigured, @@ -83,4 +87,5 @@ export const slackSetupPlugin: ChannelPlugin = { describeAccount: (account) => describeSlackSetupAccount(account), }, setup: slackSetupAdapter, + setupContract: slackSetupContract, }; diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts index 448b74c027c5..ee55dc88cb48 100644 --- a/extensions/slack/src/channel.ts +++ b/extensions/slack/src/channel.ts @@ -71,7 +71,11 @@ import type { SlackProbe } from "./probe.js"; import { resolveSlackReplyBlocks } from "./reply-blocks.js"; import { getOptionalSlackRuntime } from "./runtime.js"; import { slackSecurityAdapter } from "./security.js"; -import { createSlackSetupWizardProxy, slackSetupAdapter } from "./setup-core.js"; +import { + createSlackSetupWizardProxy, + slackSetupAdapter, + slackSetupContract, +} from "./setup-core.js"; import { createSlackPluginBase, isSlackPluginAccountConfigured, @@ -622,6 +626,7 @@ export const slackPlugin: ChannelPlugin = crea ...createSlackPluginBase({ setupWizard: createSlackSetupWizardProxy(loadSlackSetupSurfaceModule), setup: slackSetupAdapter, + setupContract: slackSetupContract, }), allowlist: { ...buildLegacyDmAccountAllowlistAdapter({ diff --git a/extensions/slack/src/setup-core.ts b/extensions/slack/src/setup-core.ts index 0c3395e7ec03..3591ebe051e0 100644 --- a/extensions/slack/src/setup-core.ts +++ b/extensions/slack/src/setup-core.ts @@ -1,4 +1,7 @@ -import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup"; +import { + defineChannelSetupContract, + type ChannelSetupInput, +} from "openclaw/plugin-sdk/channel-setup"; import { normalizeSecretInputString } from "openclaw/plugin-sdk/secret-input"; // Slack plugin module implements setup core behavior. import { @@ -277,6 +280,46 @@ export const slackSetupAdapter: ChannelSetupAdapter = { }, }; +export const slackSetupContract = defineChannelSetupContract({ + fields: { + botToken: { + kind: "string", + sensitive: true, + cli: { flags: "--bot-token ", description: "Slack bot token" }, + }, + appToken: { + kind: "string", + sensitive: true, + cli: { flags: "--app-token ", description: "Slack app token" }, + }, + userToken: { + kind: "string", + sensitive: true, + cli: { flags: "--user-token ", description: "Slack user token" }, + }, + signingSecret: { + kind: "string", + sensitive: true, + cli: { flags: "--signing-secret ", description: "Slack signing secret" }, + }, + identity: { + kind: "choice", + choices: ["bot", "user"], + cli: { flags: "--identity ", description: "Slack identity" }, + }, + mode: { + kind: "choice", + choices: ["socket", "http"], + cli: { flags: "--mode ", description: "Slack connection mode" }, + }, + useEnv: { + kind: "boolean", + cli: { flags: "--use-env", description: "Use Slack environment credentials" }, + }, + }, + legacyAdapter: slackSetupAdapter, +}); + export function createSlackSetupWizardBase(handlers: { promptAllowFrom: NonNullable; resolveAllowFromEntries: NonNullable< diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index 2f23448fd7a2..bbe81e027036 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -46,6 +46,7 @@ export const slackConfigAdapter = createScopedChannelConfigAdapter< export function createSlackPluginBase(params: { setupWizard: NonNullable["setupWizard"]>; setup: NonNullable["setup"]>; + setupContract?: NonNullable["setupContract"]>; }): Pick< ChannelPlugin, | "id" @@ -60,6 +61,7 @@ export function createSlackPluginBase(params: { | "configSchema" | "config" | "setup" + | "setupContract" | "security" | "secrets" > { @@ -70,6 +72,7 @@ export function createSlackPluginBase(params: { preferSessionLookupForAnnounceTarget: true, }, setupWizard: params.setupWizard, + ...(params.setupContract ? { setupContract: params.setupContract } : {}), capabilities: { chatTypes: ["direct", "channel", "thread"], reactions: true, diff --git a/extensions/sms/package.json b/extensions/sms/package.json index 89e4fbd45305..5850f8c3dc4d 100644 --- a/extensions/sms/package.json +++ b/extensions/sms/package.json @@ -40,7 +40,18 @@ "docsLabel": "sms", "blurb": "Twilio-backed SMS with inbound webhooks and outbound replies.", "order": 88, - "quickstartAllowFrom": true + "quickstartAllowFrom": true, + "setup": { "fields": [ + { "key": "accountSid", "kind": "string", "sensitive": true, "cli": { "flags": "--account-sid ", "description": "Twilio account SID" } }, + { "key": "authToken", "kind": "string", "sensitive": true, "cli": { "flags": "--auth-token ", "description": "Twilio auth token" } }, + { "key": "fromNumber", "kind": "string", "cli": { "flags": "--from-number ", "description": "Twilio sender phone number" } }, + { "key": "messagingServiceSid", "kind": "string", "cli": { "flags": "--messaging-service-sid ", "description": "Twilio Messaging Service SID" } }, + { "key": "defaultTo", "kind": "string", "cli": { "flags": "--default-to ", "description": "Default SMS recipient" } }, + { "key": "webhookPath", "kind": "string", "cli": { "flags": "--webhook-path ", "description": "SMS webhook path" } }, + { "key": "publicWebhookUrl", "kind": "string", "cli": { "flags": "--public-webhook-url ", "description": "Public SMS webhook URL" } }, + { "key": "dmPolicy", "kind": "choice", "choices": ["pairing", "allowlist", "open", "disabled"], "cli": { "flags": "--dm-policy ", "description": "SMS DM policy" } }, + { "key": "allowFrom", "kind": "string-list", "cli": { "flags": "--allow-from ", "description": "Allowed SMS senders" } } + ] } }, "compat": { "pluginApi": ">=2026.7.2" diff --git a/extensions/sms/src/channel.ts b/extensions/sms/src/channel.ts index c866c3f380b0..9f58ff11e574 100644 --- a/extensions/sms/src/channel.ts +++ b/extensions/sms/src/channel.ts @@ -16,7 +16,10 @@ import { defineChannelMessageAdapter, } from "openclaw/plugin-sdk/channel-outbound"; import { createConditionalWarningCollector } from "openclaw/plugin-sdk/channel-policy"; -import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup"; +import { + defineChannelSetupContract, + type ChannelSetupInput, +} from "openclaw/plugin-sdk/channel-setup"; import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime"; import { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking"; @@ -137,6 +140,51 @@ function applySmsAccountConfig(params: { return { ...params.cfg, channels }; } +const smsSetupContract = defineChannelSetupContract({ + fields: { + accountSid: { + kind: "string", + sensitive: true, + cli: { flags: "--account-sid ", description: "Twilio account SID" }, + }, + authToken: { + kind: "string", + sensitive: true, + cli: { flags: "--auth-token ", description: "Twilio auth token" }, + }, + fromNumber: { + kind: "string", + cli: { flags: "--from-number ", description: "Twilio sender phone number" }, + }, + messagingServiceSid: { + kind: "string", + cli: { flags: "--messaging-service-sid ", description: "Twilio Messaging Service SID" }, + }, + defaultTo: { + kind: "string", + cli: { flags: "--default-to ", description: "Default SMS recipient" }, + }, + webhookPath: { + kind: "string", + cli: { flags: "--webhook-path ", description: "SMS webhook path" }, + }, + publicWebhookUrl: { + kind: "string", + cli: { flags: "--public-webhook-url ", description: "Public SMS webhook URL" }, + }, + dmPolicy: { + kind: "choice", + choices: ["pairing", "allowlist", "open", "disabled"], + cli: { flags: "--dm-policy ", description: "SMS DM policy" }, + }, + allowFrom: { + kind: "string-list", + cli: { flags: "--allow-from ", description: "Allowed SMS senders" }, + }, + }, + adapter: { applyAccountConfig: applySmsAccountConfig }, +}); + function createSmsReceipt(params: { results: Array<{ sid: string; to: string; from?: string; status?: string }>; kind: "text"; @@ -254,6 +302,7 @@ export const smsPlugin: ChannelPlugin = createChat applyAccountConfig: ({ cfg, accountId, input }) => applySmsAccountConfig({ cfg, accountId, input: input as SmsSetupInput }), }, + setupContract: smsSetupContract, config: { ...smsConfigAdapter, inspectAccount: inspectSmsAccount, diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index 07f1765e9d4f..41b9e75f0ced 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -35,12 +35,12 @@ "docsLabel": "synology-chat", "blurb": "Connect your Synology NAS Chat to OpenClaw with full agent capabilities.", "order": 90, - "cliAddOptions": [ - { - "flags": "--url ", - "description": "Channel setup URL" - } - ] + "setup": { "fields": [ + { "key": "token", "kind": "string", "sensitive": true, "cli": { "flags": "--token ", "description": "Synology Chat token" } }, + { "key": "url", "kind": "string", "cli": { "flags": "--url ", "description": "Synology Chat webhook URL" } }, + { "key": "webhookPath", "kind": "string", "cli": { "flags": "--webhook-path ", "description": "Synology Chat webhook path" } }, + { "key": "useEnv", "kind": "boolean", "cli": { "flags": "--use-env", "description": "Use Synology Chat environment credentials" } } + ] } }, "install": { "npmSpec": "@openclaw/synology-chat", diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 307dfe300e90..45f67b49c461 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -43,7 +43,11 @@ import { } from "./gateway-runtime.js"; import { collectSynologyChatSecurityAuditFindings } from "./security-audit.js"; import { buildSynologyChatOutboundSessionKey } from "./session-key.js"; -import { synologyChatSetupAdapter, synologyChatSetupWizard } from "./setup-surface.js"; +import { + synologyChatSetupAdapter, + synologyChatSetupContract, + synologyChatSetupWizard, +} from "./setup-surface.js"; import type { ResolvedSynologyChatAccount } from "./types.js"; const CHANNEL_ID = "synology-chat"; @@ -327,6 +331,7 @@ function createSynologyChatPlugin(): SynologyChatPlugin { reload: { configPrefixes: [`channels.${CHANNEL_ID}`] }, configSchema: SynologyChatChannelConfigSchema, setup: synologyChatSetupAdapter, + setupContract: synologyChatSetupContract, setupWizard: synologyChatSetupWizard, config: { ...synologyChatConfigAdapter, diff --git a/extensions/synology-chat/src/setup-surface.ts b/extensions/synology-chat/src/setup-surface.ts index 5fcb3bceb245..1c76818b8c33 100644 --- a/extensions/synology-chat/src/setup-surface.ts +++ b/extensions/synology-chat/src/setup-surface.ts @@ -1,3 +1,4 @@ +import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; // Synology Chat plugin module implements setup surface behavior. import { createAllowFromSection, @@ -199,6 +200,29 @@ export const synologyChatSetupAdapter: ChannelSetupAdapter = { }, }; +export const synologyChatSetupContract = defineChannelSetupContract({ + fields: { + token: { + kind: "string", + sensitive: true, + cli: { flags: "--token ", description: "Synology Chat token" }, + }, + url: { + kind: "string", + cli: { flags: "--url ", description: "Synology Chat webhook URL" }, + }, + webhookPath: { + kind: "string", + cli: { flags: "--webhook-path ", description: "Synology Chat webhook path" }, + }, + useEnv: { + kind: "boolean", + cli: { flags: "--use-env", description: "Use Synology Chat environment credentials" }, + }, + }, + legacyAdapter: synologyChatSetupAdapter, +}); + export const synologyChatSetupWizard: ChannelSetupWizard = { channel, status: createStandardChannelSetupStatus({ diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json index 29eec8448212..b294e7d32140 100644 --- a/extensions/telegram/package.json +++ b/extensions/telegram/package.json @@ -46,6 +46,27 @@ "https://openclaw.ai" ], "markdownCapable": true, + "setup": { + "fields": [ + { + "key": "token", + "kind": "string", + "sensitive": true, + "cli": { "flags": "--token ", "description": "Telegram bot token" } + }, + { + "key": "tokenFile", + "kind": "string", + "sensitive": true, + "cli": { "flags": "--token-file ", "description": "Telegram bot token file" } + }, + { + "key": "useEnv", + "kind": "boolean", + "cli": { "flags": "--use-env", "description": "Use TELEGRAM_BOT_TOKEN" } + } + ] + }, "commands": { "nativeCommandsAutoEnabled": true, "nativeSkillsAutoEnabled": true diff --git a/extensions/telegram/src/channel.setup.ts b/extensions/telegram/src/channel.setup.ts index e3a1b537483e..7cdc20849878 100644 --- a/extensions/telegram/src/channel.setup.ts +++ b/extensions/telegram/src/channel.setup.ts @@ -2,7 +2,7 @@ import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; import type { ResolvedTelegramAccount } from "./accounts.js"; import type { TelegramProbe } from "./probe.js"; -import { telegramSetupAdapter } from "./setup-core.js"; +import { telegramSetupAdapter, telegramSetupContract } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; import { createTelegramPluginBase } from "./shared.js"; import { detectTelegramLegacyStateMigrations } from "./state-migrations.js"; @@ -11,6 +11,7 @@ export const telegramSetupPlugin: ChannelPlugin detectTelegramLegacyStateMigrations(params), diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts index 630e2c798443..d6cb10ecfa99 100644 --- a/extensions/telegram/src/channel.ts +++ b/extensions/telegram/src/channel.ts @@ -83,7 +83,7 @@ import { resolveTelegramSessionConversation, resolveTelegramSessionTarget, } from "./session-conversation.js"; -import { telegramSetupAdapter } from "./setup-core.js"; +import { telegramSetupAdapter, telegramSetupContract } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; import { createTelegramPluginBase, @@ -731,6 +731,7 @@ export const telegramPlugin = createChatChannelPlugin({ ...createTelegramPluginBase({ setupWizard: telegramSetupWizard, setup: telegramSetupAdapter, + setupContract: telegramSetupContract, }), allowlist: buildDmGroupAccountAllowlistAdapter({ channelId: "telegram", diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts index e504b84cdfa7..948505933cf9 100644 --- a/extensions/telegram/src/setup-core.ts +++ b/extensions/telegram/src/setup-core.ts @@ -1,3 +1,4 @@ +import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; // Telegram plugin module implements setup core behavior. import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/setup-runtime"; import { @@ -107,3 +108,23 @@ export const telegramSetupAdapter: ChannelSetupAdapter = { singleAccountKeysToMove, namedAccountPromotionKeys, }; + +export const telegramSetupContract = defineChannelSetupContract({ + fields: { + token: { + kind: "string", + sensitive: true, + cli: { flags: "--token ", description: "Telegram bot token" }, + }, + tokenFile: { + kind: "string", + sensitive: true, + cli: { flags: "--token-file ", description: "Telegram bot token file" }, + }, + useEnv: { + kind: "boolean", + cli: { flags: "--use-env", description: "Use TELEGRAM_BOT_TOKEN" }, + }, + }, + legacyAdapter: telegramSetupAdapter, +}); diff --git a/extensions/telegram/src/shared.ts b/extensions/telegram/src/shared.ts index 6d947a0b16f0..bba08f1f4484 100644 --- a/extensions/telegram/src/shared.ts +++ b/extensions/telegram/src/shared.ts @@ -135,6 +135,7 @@ export const telegramConfigAdapter = createScopedChannelConfigAdapter< export function createTelegramPluginBase(params: { setupWizard: NonNullable["setupWizard"]>; setup: NonNullable["setup"]>; + setupContract?: NonNullable["setupContract"]>; }): Pick< ChannelPlugin, | "id" @@ -148,10 +149,12 @@ export function createTelegramPluginBase(params: { | "configSchema" | "config" | "setup" + | "setupContract" | "secrets" > { const base = createChannelPluginBase({ id: TELEGRAM_CHANNEL, + ...(params.setupContract ? { setupContract: params.setupContract } : {}), meta: { ...getChatChannelMeta(TELEGRAM_CHANNEL), quickstartAllowFrom: true, diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index d122208f8793..79f2998ff3e3 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -43,38 +43,18 @@ "blurb": "decentralized messaging on Urbit; install the plugin to enable.", "order": 90, "quickstartAllowFrom": true, - "cliAddOptions": [ - { - "flags": "--url ", - "description": "Channel setup URL" - }, - { - "flags": "--ship ", - "description": "Tlon ship name (~sampel-palnet)" - }, - { - "flags": "--code ", - "description": "Tlon login code" - }, - { - "flags": "--group-channels ", - "description": "Tlon group channels (comma-separated)", - "valueType": "list" - }, - { - "flags": "--dm-allowlist ", - "description": "Tlon DM allowlist (comma-separated ships)", - "valueType": "list" - }, - { - "flags": "--auto-discover-channels", - "description": "Tlon auto-discover group channels" - }, - { - "flags": "--no-auto-discover-channels", - "description": "Disable Tlon auto-discovery" - } - ] + "setup": { + "fields": [ + { "key": "ship", "kind": "string", "cli": { "flags": "--ship ", "description": "Tlon ship" } }, + { "key": "url", "kind": "string", "cli": { "flags": "--url ", "description": "Tlon URL" } }, + { "key": "code", "kind": "string", "sensitive": true, "cli": { "flags": "--code ", "description": "Tlon login code" } }, + { "key": "dangerouslyAllowPrivateNetwork", "kind": "boolean", "cli": { "flags": "--dangerously-allow-private-network", "description": "Allow private-network Tlon URLs" } }, + { "key": "groupChannels", "kind": "string-list", "cli": { "flags": "--group-channels ", "description": "Tlon group channels" } }, + { "key": "dmAllowlist", "kind": "string-list", "cli": { "flags": "--dm-allowlist ", "description": "Tlon DM allowlist" } }, + { "key": "autoDiscoverChannels", "kind": "boolean", "cli": { "flags": "--auto-discover-channels", "negatedFlags": "--no-auto-discover-channels", "description": "Auto-discover Tlon group channels" } }, + { "key": "ownerShip", "kind": "string", "cli": { "flags": "--owner-ship ", "description": "Tlon owner ship" } } + ] + } }, "install": { "npmSpec": "@openclaw/tlon", diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts index 412d9fd6605c..7ac4b5f9c038 100644 --- a/extensions/tlon/src/channel.ts +++ b/extensions/tlon/src/channel.ts @@ -15,7 +15,7 @@ import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking" import { tlonChannelConfigSchema } from "./config-schema.js"; import { tlonDoctor } from "./doctor.js"; import { resolveTlonOutboundSessionRoute } from "./session-route.js"; -import { createTlonSetupWizardBase, tlonSetupAdapter } from "./setup-core.js"; +import { createTlonSetupWizardBase, tlonSetupAdapter, tlonSetupContract } from "./setup-core.js"; import { formatTargetHint, normalizeShip, @@ -108,6 +108,7 @@ export const tlonPlugin = createChatChannelPlugin({ threads: true, }, setup: tlonSetupAdapter, + setupContract: tlonSetupContract, setupWizard: tlonSetupWizardProxy, reload: { configPrefixes: ["channels.tlon"] }, configSchema: tlonChannelConfigSchema, diff --git a/extensions/tlon/src/setup-core.ts b/extensions/tlon/src/setup-core.ts index f7a465287838..f7187452c640 100644 --- a/extensions/tlon/src/setup-core.ts +++ b/extensions/tlon/src/setup-core.ts @@ -1,3 +1,4 @@ +import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; // Tlon plugin module implements setup core behavior. import { DEFAULT_ACCOUNT_ID, @@ -245,3 +246,43 @@ export const tlonSetupAdapter: ChannelSetupAdapter = { input: input as TlonSetupInput, }), }; + +export const tlonSetupContract = defineChannelSetupContract({ + fields: { + ship: { kind: "string", cli: { flags: "--ship ", description: "Tlon ship" } }, + url: { kind: "string", cli: { flags: "--url ", description: "Tlon URL" } }, + code: { + kind: "string", + sensitive: true, + cli: { flags: "--code ", description: "Tlon login code" }, + }, + dangerouslyAllowPrivateNetwork: { + kind: "boolean", + cli: { + flags: "--dangerously-allow-private-network", + description: "Allow private-network Tlon URLs", + }, + }, + groupChannels: { + kind: "string-list", + cli: { flags: "--group-channels ", description: "Tlon group channels" }, + }, + dmAllowlist: { + kind: "string-list", + cli: { flags: "--dm-allowlist ", description: "Tlon DM allowlist" }, + }, + autoDiscoverChannels: { + kind: "boolean", + cli: { + flags: "--auto-discover-channels", + negatedFlags: "--no-auto-discover-channels", + description: "Auto-discover Tlon group channels", + }, + }, + ownerShip: { + kind: "string", + cli: { flags: "--owner-ship ", description: "Tlon owner ship" }, + }, + }, + legacyAdapter: tlonSetupAdapter, +}); diff --git a/extensions/twitch/package.json b/extensions/twitch/package.json index 51a971737a2d..3029a0a2bf29 100644 --- a/extensions/twitch/package.json +++ b/extensions/twitch/package.json @@ -50,7 +50,8 @@ "blurb": "Twitch chat integration", "aliases": [ "twitch-chat" - ] + ], + "setup": { "fields": [] } }, "release": { "publishToClawHub": true, diff --git a/extensions/twitch/src/plugin.ts b/extensions/twitch/src/plugin.ts index 122820cd165a..740419a8a849 100644 --- a/extensions/twitch/src/plugin.ts +++ b/extensions/twitch/src/plugin.ts @@ -37,7 +37,7 @@ import { import { twitchMessageAdapter, twitchOutbound } from "./outbound.js"; import { probeTwitch } from "./probe.js"; import { resolveTwitchTargets } from "./resolver.js"; -import { twitchSetupAdapter, twitchSetupWizard } from "./setup-surface.js"; +import { twitchSetupAdapter, twitchSetupContract, twitchSetupWizard } from "./setup-surface.js"; import { collectTwitchStatusIssues } from "./status.js"; import type { ChannelLogSink, @@ -90,6 +90,7 @@ export const twitchPlugin: ChannelPlugin = aliases: ["twitch-chat"], }, setup: twitchSetupAdapter, + setupContract: twitchSetupContract, setupWizard: twitchSetupWizard, capabilities: { chatTypes: ["group"], diff --git a/extensions/twitch/src/setup-surface.ts b/extensions/twitch/src/setup-surface.ts index 54e7dd679b9c..0d015198de26 100644 --- a/extensions/twitch/src/setup-surface.ts +++ b/extensions/twitch/src/setup-surface.ts @@ -1,8 +1,8 @@ +import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id"; /** * Twitch setup wizard surface for CLI setup. */ - -import { normalizeOptionalAccountId } from "openclaw/plugin-sdk/account-id"; +import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; import { getChatChannelMeta, type ChannelPlugin } from "openclaw/plugin-sdk/core"; import { formatDocsLink, @@ -395,6 +395,14 @@ export const twitchSetupAdapter: ChannelSetupAdapter = { ), }; +// Intentionally empty: Twitch setup stores no flag values (the adapter only +// enables the account; credentials flow through the wizard). Shipped CLIs +// parsed-and-ignored global channel flags here; rejecting them is by design. +export const twitchSetupContract = defineChannelSetupContract({ + fields: {}, + legacyAdapter: twitchSetupAdapter, +}); + export const twitchSetupWizard: ChannelSetupWizard = { channel, resolveAccountIdForConfigure: ({ cfg, accountOverride }) => @@ -511,5 +519,6 @@ export const twitchSetupPlugin: ChannelPlugin = { isEnabled: (account) => account.enabled !== false, }, setup: twitchSetupAdapter, + setupContract: twitchSetupContract, setupWizard: twitchSetupWizard, }; diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json index b28f0ea291ab..788c88fb12b6 100644 --- a/extensions/whatsapp/package.json +++ b/extensions/whatsapp/package.json @@ -48,12 +48,18 @@ "specifier": "./auth-presence", "exportName": "hasAnyWhatsAppAuth" }, - "cliAddOptions": [ - { - "flags": "--auth-dir ", - "description": "WhatsApp auth directory override" - } - ] + "setup": { + "fields": [ + { + "key": "authDir", + "kind": "string", + "cli": { + "flags": "--auth-dir ", + "description": "WhatsApp auth directory override" + } + } + ] + } }, "install": { "clawhubSpec": "clawhub:@openclaw/whatsapp", diff --git a/extensions/whatsapp/src/channel.setup.ts b/extensions/whatsapp/src/channel.setup.ts index 9143e72d24e4..47343556a281 100644 --- a/extensions/whatsapp/src/channel.setup.ts +++ b/extensions/whatsapp/src/channel.setup.ts @@ -6,7 +6,7 @@ import { resolveWhatsAppGroupRequireMention, resolveWhatsAppGroupToolPolicy, } from "./group-policy.js"; -import { whatsappSetupAdapter } from "./setup-core.js"; +import { whatsappSetupAdapter, whatsappSetupContract } from "./setup-core.js"; import { createWhatsAppPluginBase, whatsappSetupWizardProxy } from "./shared.js"; import { detectWhatsAppLegacyStateMigrations } from "./state-migrations.js"; @@ -18,6 +18,7 @@ export const whatsappSetupPlugin: ChannelPlugin = { }, setupWizard: whatsappSetupWizardProxy, setup: whatsappSetupAdapter, + setupContract: whatsappSetupContract, isConfigured: async (account) => await isWhatsAppAuthConfigured(account.authDir), }), lifecycle: { diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index a5dc4e7cb69c..a7d6b1587a46 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -36,7 +36,7 @@ import { import { getWhatsAppRuntime } from "./runtime.js"; import { sendTypingWhatsApp } from "./send.js"; import { resolveWhatsAppOutboundSessionRoute } from "./session-route.js"; -import { whatsappSetupAdapter } from "./setup-core.js"; +import { whatsappSetupAdapter, whatsappSetupContract } from "./setup-core.js"; import { createWhatsAppPluginBase, whatsappSetupWizardProxy } from "./shared.js"; import { detectWhatsAppLegacyStateMigrations } from "./state-migrations.js"; import { collectWhatsAppStatusIssues } from "./status-issues.js"; @@ -82,6 +82,7 @@ export const whatsappPlugin: ChannelPlugin = }, setupWizard: whatsappSetupWizardProxy, setup: whatsappSetupAdapter, + setupContract: whatsappSetupContract, isConfigured: async (account) => await isWhatsAppAuthConfigured(account.authDir), }), agentTools: () => [createWhatsAppLoginTool()], diff --git a/extensions/whatsapp/src/setup-core.ts b/extensions/whatsapp/src/setup-core.ts index 4a3081d2c03a..64859cc79646 100644 --- a/extensions/whatsapp/src/setup-core.ts +++ b/extensions/whatsapp/src/setup-core.ts @@ -1,3 +1,4 @@ +import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; // Whatsapp plugin module implements setup core behavior. import { applyAccountNameToChannelSection, @@ -58,3 +59,13 @@ export const whatsappSetupAdapter: ChannelSetupAdapter = { }; }, }; + +export const whatsappSetupContract = defineChannelSetupContract({ + fields: { + authDir: { + kind: "string", + cli: { flags: "--auth-dir ", description: "WhatsApp auth directory override" }, + }, + }, + legacyAdapter: whatsappSetupAdapter, +}); diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts index a6a90ecd8072..72a62dc3ca9c 100644 --- a/extensions/whatsapp/src/shared.ts +++ b/extensions/whatsapp/src/shared.ts @@ -107,6 +107,7 @@ export function createWhatsAppPluginBase(params: { groups: NonNullable["groups"]>; setupWizard: NonNullable["setupWizard"]>; setup: NonNullable["setup"]>; + setupContract?: NonNullable["setupContract"]>; isConfigured: NonNullable["config"]>["isConfigured"]; }) { const collectWhatsAppSecurityWarnings = createAllowlistProviderGroupPolicyWarningCollector<{ @@ -211,6 +212,7 @@ export function createWhatsAppPluginBase(params: { }, doctor: whatsappDoctor, setup: params.setup, + ...(params.setupContract ? { setupContract: params.setupContract } : {}), groups: params.groups, }); return { diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 60b1823aeaa7..15dc632f3bc5 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -46,7 +46,28 @@ "zl" ], "order": 80, - "quickstartAllowFrom": true + "quickstartAllowFrom": true, + "setup": { + "fields": [ + { + "key": "token", + "kind": "string", + "sensitive": true, + "cli": { "flags": "--token ", "description": "Zalo bot token" } + }, + { + "key": "tokenFile", + "kind": "string", + "sensitive": true, + "cli": { "flags": "--token-file ", "description": "Zalo bot token file" } + }, + { + "key": "useEnv", + "kind": "boolean", + "cli": { "flags": "--use-env", "description": "Use ZALO_BOT_TOKEN" } + } + ] + } }, "install": { "npmSpec": "@openclaw/zalo", diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts index 700bb029d241..923d647b7183 100644 --- a/extensions/zalo/src/channel.ts +++ b/extensions/zalo/src/channel.ts @@ -54,7 +54,7 @@ import { ZaloConfigSchema } from "./config-schema.js"; import type { ZaloProbeResult } from "./probe.js"; import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; import { resolveZaloOutboundSessionRoute } from "./session-route.js"; -import { createZaloSetupWizardProxy, zaloSetupAdapter } from "./setup-core.js"; +import { createZaloSetupWizardProxy, zaloSetupAdapter, zaloSetupContract } from "./setup-core.js"; import { collectZaloStatusIssues } from "./status-issues.js"; const meta = { @@ -191,6 +191,7 @@ export const zaloPlugin: ChannelPlugin = id: "zalo", meta, setup: zaloSetupAdapter, + setupContract: zaloSetupContract, setupWizard: zaloSetupWizard, capabilities: { chatTypes: ["direct", "group"], diff --git a/extensions/zalo/src/setup-core.ts b/extensions/zalo/src/setup-core.ts index 385d65cef27a..014d86c374cd 100644 --- a/extensions/zalo/src/setup-core.ts +++ b/extensions/zalo/src/setup-core.ts @@ -1,3 +1,4 @@ +import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup"; // Zalo plugin module implements setup core behavior. import { addWildcardAllowFrom, @@ -47,6 +48,26 @@ export const zaloSetupAdapter = { singleAccountKeysToMove: ["webhookSecret", "tokenFile"], }; +export const zaloSetupContract = defineChannelSetupContract({ + fields: { + token: { + kind: "string", + sensitive: true, + cli: { flags: "--token ", description: "Zalo bot token" }, + }, + tokenFile: { + kind: "string", + sensitive: true, + cli: { flags: "--token-file ", description: "Zalo bot token file" }, + }, + useEnv: { + kind: "boolean", + cli: { flags: "--use-env", description: "Use ZALO_BOT_TOKEN" }, + }, + }, + legacyAdapter: zaloSetupAdapter, +}); + export const zaloDmPolicy: ChannelSetupDmPolicy = { label: "Zalo", channel, diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json index dbe0de3c3f84..b892f20458d7 100644 --- a/extensions/zalouser/package.json +++ b/extensions/zalouser/package.json @@ -54,7 +54,8 @@ "groupModel": "hybrid", "groupAllowFromFallbackToAllowFrom": false, "warnOnEmptyGroupSenderAllowlist": false - } + }, + "setup": { "fields": [] } }, "install": { "npmSpec": "@openclaw/zalouser", diff --git a/extensions/zalouser/src/channel.setup.ts b/extensions/zalouser/src/channel.setup.ts index de8fe9283e1b..1b314f4cdea7 100644 --- a/extensions/zalouser/src/channel.setup.ts +++ b/extensions/zalouser/src/channel.setup.ts @@ -1,7 +1,7 @@ // Zalouser plugin module implements channel.setup behavior. import type { ResolvedZalouserAccount } from "./accounts.js"; import type { ChannelPlugin } from "./channel-api.js"; -import { zalouserSetupAdapter } from "./setup-core.js"; +import { zalouserSetupAdapter, zalouserSetupContract } from "./setup-core.js"; import { zalouserSetupWizard } from "./setup-surface.js"; import { createZalouserPluginBase } from "./shared.js"; @@ -9,5 +9,6 @@ export const zalouserSetupPlugin: ChannelPlugin = { ...createZalouserPluginBase({ setupWizard: zalouserSetupWizard, setup: zalouserSetupAdapter, + setupContract: zalouserSetupContract, }), }; diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts index 2172853d2ff4..794577464730 100644 --- a/extensions/zalouser/src/channel.ts +++ b/extensions/zalouser/src/channel.ts @@ -29,7 +29,11 @@ import { } from "./channel.adapters.js"; import { listZalouserDirectoryGroupMembers } from "./directory.js"; import type { ZalouserProbeResult } from "./probe.js"; -import { createZalouserSetupWizardProxy, zalouserSetupAdapter } from "./setup-core.js"; +import { + createZalouserSetupWizardProxy, + zalouserSetupAdapter, + zalouserSetupContract, +} from "./setup-core.js"; import { createZalouserPluginBase } from "./shared.js"; import { collectZalouserStatusIssues } from "./status-issues.js"; @@ -72,6 +76,7 @@ export const zalouserPlugin: ChannelPlugin ({}), }); +export const zalouserSetupContract = defineChannelSetupContract({ + fields: {}, + legacyAdapter: zalouserSetupAdapter, +}); + export function createZalouserSetupWizardProxy( loadWizard: () => Promise, ): ChannelSetupWizard { diff --git a/extensions/zalouser/src/shared.ts b/extensions/zalouser/src/shared.ts index 488114fdc905..4da9ab54ef78 100644 --- a/extensions/zalouser/src/shared.ts +++ b/extensions/zalouser/src/shared.ts @@ -52,6 +52,7 @@ const zalouserConfigAdapter = createScopedChannelConfigAdapter["setupWizard"]>; setup: NonNullable["setup"]>; + setupContract?: NonNullable["setupContract"]>; }): Pick< ChannelPlugin, | "id" @@ -63,6 +64,7 @@ export function createZalouserPluginBase(params: { | "configSchema" | "config" | "setup" + | "setupContract" > { return { id: "zalouser", @@ -89,5 +91,6 @@ export function createZalouserPluginBase(params: { }), }, setup: params.setup, + ...(params.setupContract ? { setupContract: params.setupContract } : {}), }; } diff --git a/scripts/lib/official-external-channel-catalog.json b/scripts/lib/official-external-channel-catalog.json index 057f7631f82b..4356ada5c9be 100644 --- a/scripts/lib/official-external-channel-catalog.json +++ b/scripts/lib/official-external-channel-catalog.json @@ -637,6 +637,10 @@ "flags": "--signal-number ", "description": "Signal account number (E.164)" }, + { + "flags": "--signal-transport ", + "description": "Signal HTTP transport (external-native or container)" + }, { "flags": "--http-host ", "description": "Signal HTTP daemon host" diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 114af43e9ea3..e5cf72beca60 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -146,8 +146,9 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +4: session discussion state, info, provider, and registration contracts. // +2: structured media placeholder formatter and its text-fact contract. // +2: narrow settled-turn finalization result and safe full-attempt projector. + // +1: channel-owned setup contract factory. // +18: generic schema primitives needed by plugin-owned channel config schemas. - 4695, + 4696, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -155,8 +156,9 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +1: session discussion provider registration. // +1: structured media placeholder formatter for text-only channel carriers. // +1: settled-turn full-attempt projector. + // +1: channel-owned setup contract factory. // +4: generic channel schema shape builders. - 2845, + 2846, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/channels/plugins/cli-add-options.ts b/src/channels/plugins/cli-add-options.ts index 6c16457f1166..271b8e0cf589 100644 --- a/src/channels/plugins/cli-add-options.ts +++ b/src/channels/plugins/cli-add-options.ts @@ -29,14 +29,20 @@ function compareChannels(left: PluginPackageChannel, right: PluginPackageChannel : leftOrder - rightOrder; } -function matchesChannel(channel: PluginPackageChannel, normalizedChannelId: string): boolean { - return ( - channel.id?.toLowerCase() === normalizedChannelId || - Boolean(channel.aliases?.some((alias) => alias.toLowerCase() === normalizedChannelId)) - ); +function channelSetupOptions(channel: PluginPackageChannel): PluginPackageChannelCliOption[] { + // A migrating plugin may publish both surfaces so older cores keep working. + // The modern contract owns parsing here, so only its options may register; + // concatenating both would register flags that parseInput then rejects. + if (channel.setup) { + return channel.setup.fields.map((field) => field.cli); + } + return [...(channel.cliAddOptions ?? [])]; } -export function resolveChannelSetupCliOptionMetadata(channelId?: string) { +export function resolveChannelSetupCliOptionMetadata( + channelId?: string, + params: { includeAll?: boolean } = {}, +) { const bundledChannels = listBundledPackageChannelMetadata().toSorted(compareChannels); const catalogChannels = listRawChannelPluginCatalogEntries({ excludeWorkspace: true, @@ -46,31 +52,29 @@ export function resolveChannelSetupCliOptionMetadata(channelId?: string) { .toSorted(compareChannels); const orderedChannels = [...bundledChannels, ...catalogChannels]; const normalizedChannelId = channelId?.trim().toLowerCase(); - // The selected channel's declarations win switch-identity dedupe so another - // channel's same-named option cannot control parsing arity or defaults for - // this invocation. - const channels = normalizedChannelId - ? [ - ...orderedChannels.filter((channel) => matchesChannel(channel, normalizedChannelId)), - ...orderedChannels.filter((channel) => !matchesChannel(channel, normalizedChannelId)), - ] - : orderedChannels; + const selectedChannel = normalizedChannelId + ? (orderedChannels.find((channel) => channel.id?.toLowerCase() === normalizedChannelId) ?? + orderedChannels.find((channel) => + channel.aliases?.some((alias) => alias.toLowerCase() === normalizedChannelId), + )) + : undefined; + const channels = params.includeAll ? orderedChannels : selectedChannel ? [selectedChannel] : []; + // Keep pre-dedupe candidates available to detect cross-channel flag-arity conflicts. + const optionCandidates = channels.flatMap(channelSetupOptions); const seenSwitches = new Set(); - const options = channels - .flatMap((channel) => channel.cliAddOptions ?? []) - .filter((option) => { - const key = channelCliOptionSwitchKey(option.flags); - if (seenSwitches.has(key)) { - return false; - } - seenSwitches.add(key); - return true; - }); + const options = optionCandidates.filter((option) => { + const key = channelCliOptionSwitchKey(option.flags); + if (seenSwitches.has(key)) { + return false; + } + seenSwitches.add(key); + return true; + }); const valueMetadataByAttributeName = new Map(); - for (const channel of normalizedChannelId - ? channels.filter((candidate) => matchesChannel(candidate, normalizedChannelId)) - : []) { - for (const option of channel.cliAddOptions ?? []) { + // Value coercion metadata is a legacy-options mechanism; modern contracts + // type their fields, and their cliAddOptions never register above. + if (selectedChannel && !selectedChannel.setup) { + for (const option of selectedChannel.cliAddOptions ?? []) { if (!option.valueType) { continue; } @@ -82,5 +86,5 @@ export function resolveChannelSetupCliOptionMetadata(channelId?: string) { } } - return { options, valueMetadataByAttributeName }; + return { options, optionCandidates, selectedChannel, valueMetadataByAttributeName }; } diff --git a/src/channels/plugins/contracts/plugin-shape.contract.test.ts b/src/channels/plugins/contracts/plugin-shape.contract.test.ts index a33785096b42..d27f7ec7404d 100644 --- a/src/channels/plugins/contracts/plugin-shape.contract.test.ts +++ b/src/channels/plugins/contracts/plugin-shape.contract.test.ts @@ -15,6 +15,7 @@ // - blockStreaming=true does not imply a streaming adapter (coalesce tuning // is optional). import { beforeAll, describe, expect, it } from "vitest"; +import { listBundledPackageChannelMetadata } from "../../../plugins/bundled-package-channel-metadata.js"; import { getBundledChannelPluginAsync, listBundledChannelPluginIds, @@ -22,6 +23,9 @@ import { const CHAT_TYPES = new Set(["direct", "group", "channel", "thread"]); const bundledChannelPluginIds = listBundledChannelPluginIds(); +const packageMetadataById = new Map( + listBundledPackageChannelMetadata().map((channel) => [channel.id, channel]), +); describe("bundled channel plugin shape coherence", () => { const plugins = new Map>>(); @@ -51,6 +55,17 @@ describe("bundled channel plugin shape coherence", () => { expect(plugin?.meta.docsPath.trim()).toBeTruthy(); }); + it("keeps runtime and lazy setup metadata on the same channel-owned contract", () => { + const plugin = plugins.get(id); + const packageSetup = packageMetadataById.get(id)?.setup; + if (!plugin?.setup && !plugin?.setupContract && !packageSetup) { + return; + } + expect(plugin?.setupContract, `${id} must expose setupContract`).toBeDefined(); + expect(packageSetup, `${id} must expose package setup metadata`).toBeDefined(); + expect(plugin?.setupContract?.metadata).toEqual(packageSetup); + }); + it("declares known chat types", () => { const chatTypes = plugins.get(id)?.capabilities.chatTypes ?? []; expect(chatTypes.length).toBeGreaterThan(0); diff --git a/src/channels/plugins/setup-contract.test.ts b/src/channels/plugins/setup-contract.test.ts new file mode 100644 index 000000000000..e5471093c945 --- /dev/null +++ b/src/channels/plugins/setup-contract.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it, vi } from "vitest"; +import { defineChannelSetupContract } from "./setup-contract.js"; + +describe("defineChannelSetupContract", () => { + it("requires field keys to match camelCased long flag names", () => { + expect(() => + defineChannelSetupContract({ + fields: { + credential: { + kind: "string", + cli: { flags: "--token ", description: "Credential" }, + }, + }, + adapter: { applyAccountConfig: ({ cfg }) => cfg }, + }), + ).toThrow('Channel setup field "credential" must match camelCased long flag name "token"'); + + expect(() => + defineChannelSetupContract({ + fields: { + apiToken: { + kind: "string", + cli: { flags: "--api-token ", description: "API token" }, + }, + }, + adapter: { applyAccountConfig: ({ cfg }) => cfg }, + }), + ).not.toThrow(); + }); + + it("publishes the validated map key over a field-owned key property", () => { + const contract = defineChannelSetupContract({ + fields: { + apiToken: { + key: "credential", + kind: "string", + cli: { flags: "--api-token ", description: "API token" }, + } as const, + }, + adapter: { applyAccountConfig: ({ cfg }) => cfg }, + }); + + expect(contract.metadata.fields[0]?.key).toBe("apiToken"); + }); + + it("requires negated flags to resolve to the same field key", () => { + expect(() => + defineChannelSetupContract({ + fields: { + tenant: { + kind: "boolean", + cli: { + flags: "--tenant", + negatedFlags: "--no-other", + description: "Tenant mode", + }, + }, + }, + adapter: { applyAccountConfig: ({ cfg }) => cfg }, + }), + ).toThrow('Channel setup field "tenant" must match camelCased long flag name "other"'); + + expect(() => + defineChannelSetupContract({ + fields: { + useEnv: { + kind: "boolean", + cli: { + flags: "--use-env", + negatedFlags: "--no-use-env", + description: "Use environment credentials", + }, + }, + }, + adapter: { applyAccountConfig: ({ cfg }) => cfg }, + }), + ).not.toThrow(); + }); + + it("parses channel-owned fields and gives adapters inferred input types", () => { + const applyAccountConfig = vi.fn( + ({ + input, + }: { + input: { + name?: string; + token?: string; + transport?: "external-native" | "container"; + port?: number; + allowFrom?: string[]; + useEnv?: boolean; + }; + }) => { + void input.token; + return {}; + }, + ); + const contract = defineChannelSetupContract({ + fields: { + token: { + kind: "string", + cli: { flags: "--token ", description: "Bot token" }, + }, + transport: { + kind: "choice", + choices: ["external-native", "container"], + cli: { flags: "--transport ", description: "Transport kind" }, + }, + port: { + kind: "integer", + cli: { flags: "--port ", description: "HTTP port" }, + }, + allowFrom: { + kind: "string-list", + cli: { flags: "--allow-from ", description: "Allowed sender ids" }, + }, + useEnv: { + kind: "boolean", + cli: { flags: "--use-env", description: "Use environment credentials" }, + }, + }, + adapter: { + applyAccountConfig, + }, + }); + + const parsed = contract.parseInput({ + name: "work", + token: "secret", + transport: "container", + port: "8080", + allowFrom: "alice,bob", + useEnv: true, + }); + + expect(parsed).toEqual({ + ok: true, + value: { + name: "work", + token: "secret", + transport: "container", + port: 8080, + allowFrom: ["alice", "bob"], + useEnv: true, + }, + }); + if (!parsed.ok) { + throw new Error(parsed.error); + } + expect( + contract.applyAccountConfig({ cfg: {}, accountId: "default", input: parsed.value }), + ).toEqual({}); + expect(applyAccountConfig).toHaveBeenCalledWith({ + cfg: {}, + accountId: "default", + input: { + name: "work", + token: "secret", + transport: "container", + port: 8080, + allowFrom: ["alice", "bob"], + useEnv: true, + }, + }); + }); + + it("rejects unsupported fields and invalid channel-owned values", () => { + const contract = defineChannelSetupContract({ + fields: { + transport: { + kind: "choice", + choices: ["external-native", "container"], + cli: { flags: "--transport ", description: "Transport kind" }, + }, + port: { + kind: "integer", + cli: { flags: "--port ", description: "HTTP port" }, + }, + }, + adapter: { + applyAccountConfig: ({ cfg }) => cfg, + }, + }); + + expect(contract.parseInput({ token: "wrong channel" })).toEqual({ + ok: false, + error: "Unsupported setup option: token", + }); + expect(contract.parseInput({ transport: "managed" })).toEqual({ + ok: false, + error: 'transport must be one of: "external-native", "container".', + }); + expect(contract.parseInput({ port: "8.5" })).toEqual({ + ok: false, + error: "port must be a non-negative integer.", + }); + }); + + it("projects serializable CLI metadata from the same field definition", () => { + const contract = defineChannelSetupContract({ + fields: { + token: { + kind: "string", + sensitive: true, + cli: { flags: "--token ", description: "Bot token" }, + }, + mode: { + kind: "choice", + choices: ["socket", "http"], + cli: { flags: "--mode ", description: "Connection mode" }, + }, + }, + adapter: { + applyAccountConfig: ({ cfg }) => cfg, + }, + }); + + expect(contract.metadata).toEqual({ + fields: [ + { + key: "token", + kind: "string", + sensitive: true, + cli: { flags: "--token ", description: "Bot token" }, + }, + { + key: "mode", + kind: "choice", + choices: ["socket", "http"], + cli: { flags: "--mode ", description: "Connection mode" }, + }, + ], + }); + }); +}); diff --git a/src/channels/plugins/setup-contract.ts b/src/channels/plugins/setup-contract.ts new file mode 100644 index 000000000000..ade60cb7fdb1 --- /dev/null +++ b/src/channels/plugins/setup-contract.ts @@ -0,0 +1,429 @@ +import { Option } from "commander"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { parseStrictNonNegativeInteger } from "../../infra/parse-finite-number.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import type { ChannelSetupInput } from "./setup-input.js"; + +type ChannelSetupCliOption = { + flags: string; + negatedFlags?: string; + description: string; + defaultValue?: boolean | string; +}; + +type ChannelSetupStringField = { + kind: "string"; + sensitive?: boolean; + cli: ChannelSetupCliOption; +}; + +type ChannelSetupBooleanField = { + kind: "boolean"; + cli: ChannelSetupCliOption; +}; + +type ChannelSetupIntegerField = { + kind: "integer"; + cli: ChannelSetupCliOption; +}; + +type ChannelSetupStringListField = { + kind: "string-list"; + sensitive?: boolean; + cli: ChannelSetupCliOption; +}; + +type ChannelSetupChoiceField = { + kind: "choice"; + choices: Choices; + cli: ChannelSetupCliOption; +}; + +type ChannelSetupField = + | ChannelSetupStringField + | ChannelSetupBooleanField + | ChannelSetupIntegerField + | ChannelSetupStringListField + | ChannelSetupChoiceField; + +export type ChannelSetupFieldMetadata = ChannelSetupField & { + key: string; +}; + +export type ChannelSetupMetadata = { + fields: readonly ChannelSetupFieldMetadata[]; +}; + +export function resolveChannelSetupFieldCliAttributeName(flags: string): string | undefined { + const option = new Option(flags); + return option.long ? option.attributeName() : undefined; +} + +function assertChannelSetupFieldCliAttributeName(key: string, flags: string): void { + let attributeName: string | undefined; + try { + attributeName = resolveChannelSetupFieldCliAttributeName(flags); + } catch { + throw new Error(`Channel setup field "${key}" has invalid CLI flags "${flags}".`); + } + if (!attributeName) { + throw new Error(`Channel setup field "${key}" must declare a long CLI flag.`); + } + if (attributeName !== key) { + throw new Error( + `Channel setup field "${key}" must match camelCased long flag name "${attributeName}" from "${flags}".`, + ); + } +} + +type ChannelSetupFieldValue = Field extends { + kind: "boolean"; +} + ? boolean + : Field extends { kind: "integer" } + ? number + : Field extends { kind: "string-list" } + ? string[] + : Field extends { kind: "choice"; choices: infer Choices extends readonly string[] } + ? Choices[number] + : string; + +type ChannelSetupInputForFields> = { + name?: string; +} & { + [Key in keyof Fields]?: ChannelSetupFieldValue; +}; + +type ChannelSetupParseResult = { ok: true; value: unknown } | { ok: false; error: string }; + +type ChannelSetupContractAdapterParams> = + | { + adapter: ChannelOwnedSetupAdapterShape>; + legacyAdapter?: never; + } + | { + adapter?: never; + legacyAdapter: ChannelOwnedSetupAdapterShape; + }; + +type ChannelOwnedSetupAdapterShape = { + resolveAccountId?: (params: { cfg: OpenClawConfig; accountId?: string; input?: Input }) => string; + prepareAccountConfigInput?: (params: { + cfg: OpenClawConfig; + accountId: string; + input: Input; + runtime: RuntimeEnv; + }) => Promise | Input; + resolveBindingAccountId?: (params: { + cfg: OpenClawConfig; + agentId: string; + accountId?: string; + }) => string | undefined; + applyAccountName?: (params: { + cfg: OpenClawConfig; + accountId: string; + name?: string; + }) => OpenClawConfig; + applyAccountConfig: (params: { + cfg: OpenClawConfig; + accountId: string; + input: Input; + }) => OpenClawConfig; + afterAccountConfigWritten?: (params: { + previousCfg: OpenClawConfig; + cfg: OpenClawConfig; + accountId: string; + input: Input; + runtime: RuntimeEnv; + }) => Promise | void; + validateInput?: (params: { + cfg: OpenClawConfig; + accountId: string; + input: Input; + }) => string | null; + singleAccountKeysToMove?: readonly string[]; + namedAccountPromotionKeys?: readonly string[]; + resolveSingleAccountPromotionTarget?: (params: { + channel: Record; + }) => string | undefined; +}; + +export type ChannelOwnedSetupContract = { + kind: "channel-owned"; + metadata: ChannelSetupMetadata; + parseInput: (input: unknown) => ChannelSetupParseResult; + resolveAccountId?: (params: { + cfg: OpenClawConfig; + accountId?: string; + input?: unknown; + }) => string; + prepareAccountConfigInput?: (params: { + cfg: OpenClawConfig; + accountId: string; + input: unknown; + runtime: RuntimeEnv; + }) => Promise | object; + resolveBindingAccountId?: ChannelOwnedSetupAdapterShape<{ + name?: string; + }>["resolveBindingAccountId"]; + applyAccountName?: ChannelOwnedSetupAdapterShape<{ name?: string }>["applyAccountName"]; + applyAccountConfig: (params: { + cfg: OpenClawConfig; + accountId: string; + input: unknown; + }) => OpenClawConfig; + afterAccountConfigWritten?: (params: { + previousCfg: OpenClawConfig; + cfg: OpenClawConfig; + accountId: string; + input: unknown; + runtime: RuntimeEnv; + }) => Promise | void; + validateInput?: (params: { + cfg: OpenClawConfig; + accountId: string; + input: unknown; + }) => string | null; + singleAccountKeysToMove?: readonly string[]; + namedAccountPromotionKeys?: readonly string[]; + resolveSingleAccountPromotionTarget?: ChannelOwnedSetupAdapterShape<{ + name?: string; + }>["resolveSingleAccountPromotionTarget"]; +}; + +type ChannelSetupExecutionAdapter = Omit< + ChannelOwnedSetupContract, + "kind" | "metadata" | "parseInput" +>; + +/** Adapts the released shared-bag contract at one explicit compatibility boundary. */ +export function resolveChannelSetupExecutionAdapter(plugin: { + setupContract?: ChannelOwnedSetupContract; + setup?: ChannelOwnedSetupAdapterShape; +}): ChannelSetupExecutionAdapter | undefined { + if (plugin.setupContract) { + return plugin.setupContract; + } + const legacy = plugin.setup; + if (!legacy) { + return undefined; + } + const legacyInput = (input: unknown): ChannelSetupInput => input as ChannelSetupInput; + const prepareAccountConfigInput = legacy.prepareAccountConfigInput; + return { + ...(legacy.resolveAccountId + ? { + resolveAccountId: (params) => + legacy.resolveAccountId?.({ ...params, input: legacyInput(params.input) }) ?? + params.accountId ?? + "default", + } + : {}), + ...(prepareAccountConfigInput + ? { + prepareAccountConfigInput: (params) => + prepareAccountConfigInput({ + ...params, + input: legacyInput(params.input), + }), + } + : {}), + resolveBindingAccountId: legacy.resolveBindingAccountId, + applyAccountName: legacy.applyAccountName, + applyAccountConfig: (params) => + legacy.applyAccountConfig({ ...params, input: legacyInput(params.input) }), + ...(legacy.afterAccountConfigWritten + ? { + afterAccountConfigWritten: (params) => + legacy.afterAccountConfigWritten?.({ + ...params, + input: legacyInput(params.input), + }), + } + : {}), + ...(legacy.validateInput + ? { + validateInput: (params) => + legacy.validateInput?.({ ...params, input: legacyInput(params.input) }) ?? null, + } + : {}), + singleAccountKeysToMove: legacy.singleAccountKeysToMove, + namedAccountPromotionKeys: legacy.namedAccountPromotionKeys, + resolveSingleAccountPromotionTarget: legacy.resolveSingleAccountPromotionTarget, + }; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function parseStringList(value: unknown): string[] | undefined { + if (Array.isArray(value)) { + return value.every((entry) => typeof entry === "string") ? value : undefined; + } + if (typeof value !== "string") { + return undefined; + } + return value + .split(/[\n,]/u) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function parseFieldValue( + key: string, + field: ChannelSetupField, + value: unknown, +): { ok: true; value: unknown } | { ok: false; error: string } { + if (field.kind === "string") { + return typeof value === "string" + ? { ok: true, value } + : { ok: false, error: `${key} must be a string.` }; + } + if (field.kind === "boolean") { + return typeof value === "boolean" + ? { ok: true, value } + : { ok: false, error: `${key} must be true or false.` }; + } + if (field.kind === "integer") { + const parsed = parseStrictNonNegativeInteger(value); + return parsed === undefined + ? { ok: false, error: `${key} must be a non-negative integer.` } + : { ok: true, value: parsed }; + } + if (field.kind === "string-list") { + const parsed = parseStringList(value); + return parsed + ? { ok: true, value: parsed } + : { ok: false, error: `${key} must be a comma-separated list of strings.` }; + } + if (typeof value !== "string" || !field.choices.includes(value)) { + return { + ok: false, + error: `${key} must be one of: ${field.choices.map((choice) => JSON.stringify(choice)).join(", ")}.`, + }; + } + return { ok: true, value }; +} + +function parseSetupInput>( + fields: Fields, + rawInput: unknown, +): { ok: true; value: ChannelSetupInputForFields } | { ok: false; error: string } { + if (!isRecord(rawInput)) { + return { ok: false, error: "Channel setup input must be an object." }; + } + const value: Record = {}; + for (const [key, rawValue] of Object.entries(rawInput)) { + if (rawValue === undefined) { + continue; + } + if (key === "name") { + if (typeof rawValue !== "string") { + return { ok: false, error: "name must be a string." }; + } + value.name = rawValue; + continue; + } + const field = fields[key]; + if (!field) { + return { ok: false, error: `Unsupported setup option: ${key}` }; + } + const parsed = parseFieldValue(key, field, rawValue); + if (!parsed.ok) { + return parsed; + } + value[key] = parsed.value; + } + // Every property was checked against the field map above. This assertion is + // the single dynamic-object boundary; plugin callbacks remain fully typed. + return { ok: true, value: value as ChannelSetupInputForFields }; +} + +function requireParsedInput>( + fields: Fields, + input: unknown, +): ChannelSetupInputForFields { + const parsed = parseSetupInput(fields, input); + if (!parsed.ok) { + throw new Error(parsed.error); + } + return parsed.value; +} + +export function defineChannelSetupContract>( + params: { fields: Fields } & ChannelSetupContractAdapterParams, +): ChannelOwnedSetupContract { + const { fields } = params; + const fieldEntries = Object.entries(fields); + // The field key crosses serialized projections, Commander parsing, and parseInput. + // Match Commander's attribute name so all three stay aligned by construction. + for (const [key, field] of fieldEntries) { + assertChannelSetupFieldCliAttributeName(key, field.cli.flags); + if (field.cli.negatedFlags) { + assertChannelSetupFieldCliAttributeName(key, field.cli.negatedFlags); + } + } + const adapter = + params.adapter ?? + (params.legacyAdapter as ChannelOwnedSetupAdapterShape>); + const prepareAccountConfigInput = adapter.prepareAccountConfigInput; + const metadata: ChannelSetupMetadata = { + fields: fieldEntries.map(([key, field]) => Object.assign({}, field, { key })), + }; + return { + kind: "channel-owned", + metadata, + parseInput: (input) => parseSetupInput(fields, input), + ...(adapter.resolveAccountId + ? { + resolveAccountId: (inputParams) => + adapter.resolveAccountId?.({ + ...inputParams, + input: requireParsedInput(fields, inputParams.input ?? {}), + }) ?? + inputParams.accountId ?? + "default", + } + : {}), + ...(prepareAccountConfigInput + ? { + prepareAccountConfigInput: async (inputParams) => + await prepareAccountConfigInput({ + ...inputParams, + input: requireParsedInput(fields, inputParams.input), + }), + } + : {}), + resolveBindingAccountId: adapter.resolveBindingAccountId, + applyAccountName: adapter.applyAccountName, + applyAccountConfig: (inputParams) => + adapter.applyAccountConfig({ + ...inputParams, + input: requireParsedInput(fields, inputParams.input), + }), + ...(adapter.afterAccountConfigWritten + ? { + afterAccountConfigWritten: (inputParams) => + adapter.afterAccountConfigWritten?.({ + ...inputParams, + input: requireParsedInput(fields, inputParams.input), + }), + } + : {}), + ...(adapter.validateInput + ? { + validateInput: (inputParams) => { + const parsed = parseSetupInput(fields, inputParams.input); + if (!parsed.ok) { + return parsed.error; + } + return adapter.validateInput?.({ ...inputParams, input: parsed.value }) ?? null; + }, + } + : {}), + singleAccountKeysToMove: adapter.singleAccountKeysToMove, + namedAccountPromotionKeys: adapter.namedAccountPromotionKeys, + resolveSingleAccountPromotionTarget: adapter.resolveSingleAccountPromotionTarget, + }; +} diff --git a/src/channels/plugins/setup-helpers.ts b/src/channels/plugins/setup-helpers.ts index 50b4b0a85b6f..fdd91dd79295 100644 --- a/src/channels/plugins/setup-helpers.ts +++ b/src/channels/plugins/setup-helpers.ts @@ -4,7 +4,6 @@ import { expectDefined } from "@openclaw/normalization-core"; * * Applies account names and validates setup results for channel onboarding adapters. */ -import { z, type ZodType } from "zod"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { resolveSingleAccountKeysToMove } from "./setup-promotion-helpers.js"; @@ -174,14 +173,16 @@ export function applySetupAccountConfigPatch(params: { } /** Creates a setup adapter that turns validated setup input into an account config patch. */ -export function createPatchedAccountSetupAdapter(params: { +export function createPatchedAccountSetupAdapter< + Input extends { name?: string } = ChannelSetupInput, +>(params: { channelKey: string; alwaysUseAccounts?: boolean; ensureChannelEnabled?: boolean; ensureAccountEnabled?: boolean; - validateInput?: ChannelSetupAdapter["validateInput"]; - buildPatch: (input: ChannelSetupInput) => Record; -}): ChannelSetupAdapter { + validateInput?: ChannelSetupAdapter["validateInput"]; + buildPatch: (input: Input) => Record; +}): ChannelSetupAdapter { return { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => @@ -217,30 +218,6 @@ export function createPatchedAccountSetupAdapter(params: { }; } -function createZodSetupInputValidator(params: { - schema: ZodType; - validate?: (params: { cfg: OpenClawConfig; accountId: string; input: T }) => string | null; -}): NonNullable { - return (inputParams) => { - const parsed = params.schema.safeParse(inputParams.input); - if (!parsed.success) { - return parsed.error.issues[0]?.message ?? "invalid input"; - } - return ( - params.validate?.({ - ...inputParams, - input: parsed.data, - }) ?? null - ); - }; -} - -const GenericSetupInputSchema = z - .object({ - useEnv: z.boolean().optional(), - }) - .passthrough() as ZodType; - type SetupInputPresenceRequirement = { someOf: string[]; message: string; @@ -253,37 +230,32 @@ function hasPresentSetupValue(value: unknown): boolean { return value !== undefined && value !== null; } -export function createSetupInputPresenceValidator(params: { +export function createSetupInputPresenceValidator< + Input extends { name?: string; useEnv?: boolean } = ChannelSetupInput, +>(params: { defaultAccountOnlyEnvError?: string; whenNotUseEnv?: SetupInputPresenceRequirement[]; - validate?: (params: { - cfg: OpenClawConfig; - accountId: string; - input: ChannelSetupInput; - }) => string | null; -}): NonNullable { - return createZodSetupInputValidator({ - schema: GenericSetupInputSchema, - validate: (inputParams) => { - if ( - params.defaultAccountOnlyEnvError && - inputParams.input.useEnv && - inputParams.accountId !== DEFAULT_ACCOUNT_ID - ) { - return params.defaultAccountOnlyEnvError; - } - if (!inputParams.input.useEnv) { - const inputRecord = inputParams.input as Record; - for (const requirement of params.whenNotUseEnv ?? []) { - if (requirement.someOf.some((key) => hasPresentSetupValue(inputRecord[key]))) { - continue; - } - return requirement.message; + validate?: (params: { cfg: OpenClawConfig; accountId: string; input: Input }) => string | null; +}): NonNullable["validateInput"]> { + return (inputParams) => { + if ( + params.defaultAccountOnlyEnvError && + inputParams.input.useEnv && + inputParams.accountId !== DEFAULT_ACCOUNT_ID + ) { + return params.defaultAccountOnlyEnvError; + } + if (!inputParams.input.useEnv) { + const inputRecord = inputParams.input as Record; + for (const requirement of params.whenNotUseEnv ?? []) { + if (requirement.someOf.some((key) => hasPresentSetupValue(inputRecord[key]))) { + continue; } + return requirement.message; } - return params.validate?.(inputParams) ?? null; - }, - }); + } + return params.validate?.(inputParams) ?? null; + }; } /** Creates a setup adapter that supports env-backed default account auth and patched credentials. */ diff --git a/src/channels/plugins/setup-input.ts b/src/channels/plugins/setup-input.ts new file mode 100644 index 000000000000..4eb76d18aa64 --- /dev/null +++ b/src/channels/plugins/setup-input.ts @@ -0,0 +1,62 @@ +type ChannelSetupEnvelope = { + name?: string; + token?: string; + tokenFile?: string; + useEnv?: boolean; + defaultTo?: string; + allowFrom?: string[]; +}; + +/** + * Compatibility fields with known published readers in the 2026-07-22 registry sweep. + * Each field is deleted as soon as no published plugin reads it; no version boundary is needed. + */ +type DeprecatedChannelSetupFields = { + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + privateKey?: string; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + secret?: string; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + botToken?: string; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + appToken?: string; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + signingSecret?: string; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + mode?: "socket" | "http" | "relay"; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + cliPath?: string; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + authDir?: string; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + httpUrl?: string; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + httpPort?: string; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + webhookPath?: string; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + webhookUrl?: string; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + userId?: string; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + accessToken?: string; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + password?: string; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + deviceName?: string; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + url?: string; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + baseUrl?: string; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + code?: string; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + groupChannels?: string[]; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + dmAllowlist?: string[]; + /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ + autoDiscoverChannels?: boolean; +}; + +/** Generic setup envelope used by CLI, onboarding, and channel-owned setup adapters. */ +export type ChannelSetupInput = ChannelSetupEnvelope & DeprecatedChannelSetupFields; diff --git a/src/channels/plugins/setup-wizard-types.ts b/src/channels/plugins/setup-wizard-types.ts index c9db4fdd8633..9403547f11e1 100644 --- a/src/channels/plugins/setup-wizard-types.ts +++ b/src/channels/plugins/setup-wizard-types.ts @@ -7,6 +7,7 @@ import type { DmPolicy } from "../../config/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; +import type { ChannelOwnedSetupContract } from "./setup-contract.js"; import type { ChannelAccessPolicy } from "./setup-group-access.js"; import type { ChannelConfigAdapter, ChannelSetupAdapter } from "./types.adapters.js"; import type { ChannelCapabilities, ChannelId, ChannelMeta } from "./types.core.js"; @@ -16,6 +17,7 @@ export type ChannelSetupPlugin = { meta: ChannelMeta; capabilities: ChannelCapabilities; config: ChannelConfigAdapter; + setupContract?: ChannelOwnedSetupContract; setup?: ChannelSetupAdapter; setupWizard?: ChannelSetupWizard | ChannelSetupWizardAdapter; }; diff --git a/src/channels/plugins/setup-wizard.ts b/src/channels/plugins/setup-wizard.ts index 979544e57860..404505812b32 100644 --- a/src/channels/plugins/setup-wizard.ts +++ b/src/channels/plugins/setup-wizard.ts @@ -6,6 +6,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; +import { resolveChannelSetupExecutionAdapter } from "./setup-contract.js"; import { configureChannelAccessWithAllowlist } from "./setup-group-access-configure.js"; import { moveSingleAccountChannelSectionToDefaultAccount } from "./setup-helpers.js"; import { @@ -142,20 +143,28 @@ function applySetupInput(params: { accountId: string; input: ChannelSetupInput; }) { - const setup = params.plugin.setup; + const setup = resolveChannelSetupExecutionAdapter(params.plugin); if (!setup?.applyAccountConfig) { throw new Error(`${params.plugin.id} does not support setup`); } + let input: unknown = params.input; + if (params.plugin.setupContract) { + const parsed = params.plugin.setupContract.parseInput(input); + if (!parsed.ok) { + throw new Error(parsed.error); + } + input = parsed.value; + } const resolvedAccountId = setup.resolveAccountId?.({ cfg: params.cfg, accountId: params.accountId, - input: params.input, + input, }) ?? params.accountId; const validationError = setup.validateInput?.({ cfg: params.cfg, accountId: resolvedAccountId, - input: params.input, + input, }); if (validationError) { throw new Error(validationError); @@ -163,7 +172,7 @@ function applySetupInput(params: { let next = setup.applyAccountConfig({ cfg: params.cfg, accountId: resolvedAccountId, - input: params.input, + input, }); if (params.input.name?.trim() && setup.applyAccountName) { next = setup.applyAccountName({ diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index ce63a0c90d84..aa4a974016b5 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -21,6 +21,7 @@ import type { SecretTargetRegistryEntry } from "../../secrets/target-registry-ty import type { ChannelApprovalNativeAdapter } from "./approval-native.types.js"; import type { ChannelRuntimeSurface } from "./channel-runtime-surface.types.js"; import type { ConfigWriteTarget } from "./config-writes.js"; +import type { ChannelSetupInput } from "./setup-input.js"; export type { ChannelOutboundAdapter, ChannelOutboundContext, @@ -39,7 +40,6 @@ import type { ChannelLogSink, ChannelSecurityContext, ChannelSecurityDmPolicy, - ChannelSetupInput, ChannelStatusIssue, } from "./types.core.js"; export type { ChannelPairingAdapter } from "./pairing.types.js"; @@ -73,18 +73,14 @@ export type ChannelCapabilitiesDiagnostics = { type ChannelAdapterCallback unknown> = T; -export type ChannelSetupAdapter = { - resolveAccountId?: (params: { - cfg: OpenClawConfig; - accountId?: string; - input?: ChannelSetupInput; - }) => string; +export type ChannelSetupAdapter = { + resolveAccountId?: (params: { cfg: OpenClawConfig; accountId?: string; input?: Input }) => string; prepareAccountConfigInput?: (params: { cfg: OpenClawConfig; accountId: string; - input: ChannelSetupInput; + input: Input; runtime: RuntimeEnv; - }) => Promise | ChannelSetupInput; + }) => Promise | Input; resolveBindingAccountId?: (params: { cfg: OpenClawConfig; agentId: string; @@ -98,19 +94,19 @@ export type ChannelSetupAdapter = { applyAccountConfig: (params: { cfg: OpenClawConfig; accountId: string; - input: ChannelSetupInput; + input: Input; }) => OpenClawConfig; afterAccountConfigWritten?: (params: { previousCfg: OpenClawConfig; cfg: OpenClawConfig; accountId: string; - input: ChannelSetupInput; + input: Input; runtime: RuntimeEnv; }) => Promise | void; validateInput?: (params: { cfg: OpenClawConfig; accountId: string; - input: ChannelSetupInput; + input: Input; }) => string | null; singleAccountKeysToMove?: readonly string[]; namedAccountPromotionKeys?: readonly string[]; diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index 575b73a76aa8..e8ebb6e78bbd 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -25,6 +25,7 @@ import type { ChannelMessageCapability } from "./message-capabilities.js"; export type { ChannelId } from "./channel-id.types.js"; export type { ChannelLegacyStateMigrationPlan } from "./legacy-state-migration.types.js"; +export type { ChannelSetupInput } from "./setup-input.js"; type ChannelExposure = { configured?: boolean; @@ -95,69 +96,6 @@ export type ChannelMessageToolDiscovery = { mediaSourceParams?: ChannelMessageToolMediaSourceParams | null; }; -type ChannelSetupEnvelope = { - name?: string; - token?: string; - tokenFile?: string; - useEnv?: boolean; - defaultTo?: string; - allowFrom?: string[]; -}; - -/** - * Compatibility fields with known published readers in the 2026-07-22 registry sweep. - * Each field is deleted as soon as no published plugin reads it; no version boundary is needed. - */ -type DeprecatedChannelSetupFields = { - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - privateKey?: string; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - secret?: string; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - botToken?: string; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - appToken?: string; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - signingSecret?: string; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - mode?: "socket" | "http" | "relay"; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - cliPath?: string; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - authDir?: string; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - httpUrl?: string; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - httpPort?: string; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - webhookPath?: string; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - webhookUrl?: string; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - userId?: string; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - accessToken?: string; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - password?: string; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - deviceName?: string; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - url?: string; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - baseUrl?: string; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - code?: string; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - groupChannels?: string[]; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - dmAllowlist?: string[]; - /** @deprecated Declare this field in the owning plugin's setup input type: https://docs.openclaw.ai/plugins/sdk-setup#channel-owned-setup-input-fields. Removed once no published plugin reads it. */ - autoDiscoverChannels?: boolean; -}; - -/** Generic setup envelope used by CLI, onboarding, and channel-owned setup adapters. */ -export type ChannelSetupInput = ChannelSetupEnvelope & DeprecatedChannelSetupFields; - export type ChannelStatusIssue = { channel: ChannelId; accountId: string; diff --git a/src/channels/plugins/types.plugin.ts b/src/channels/plugins/types.plugin.ts index da3e389ed95d..572c116183fd 100644 --- a/src/channels/plugins/types.plugin.ts +++ b/src/channels/plugins/types.plugin.ts @@ -5,6 +5,7 @@ import type { OperatorScope } from "../../gateway/operator-scopes.js"; * Defines the full plugin object shape composed from config, runtime, setup, and adapter surfaces. */ import type { ChannelMessageAdapterShape } from "../message/types.js"; +import type { ChannelOwnedSetupContract } from "./setup-contract.js"; import type { ChannelSetupWizard, ChannelSetupWizardAdapter } from "./setup-wizard-types.js"; import type { ChannelApprovalCapability, @@ -79,6 +80,9 @@ export type ChannelPlugin; configSchema?: ChannelConfigSchema; + /** Channel-owned typed setup contract. Preferred over the legacy shared input adapter. */ + setupContract?: ChannelOwnedSetupContract; + /** @deprecated Use setupContract for new plugins. */ setup?: ChannelSetupAdapter; pairing?: ChannelPairingAdapter; security?: ChannelSecurityAdapter; diff --git a/src/cli/channels-cli-add-args.ts b/src/cli/channels-cli-add-args.ts new file mode 100644 index 000000000000..9673d6db372e --- /dev/null +++ b/src/cli/channels-cli-add-args.ts @@ -0,0 +1,147 @@ +import { Option, type Command } from "commander"; +import { createLazyImportLoader } from "../shared/lazy-promise.js"; +import { normalizeWindowsArgv } from "./windows-argv.js"; + +type ChannelSetupCliOptionsModule = typeof import("../channels/plugins/cli-add-options.js"); +type ChannelSetupFlagArity = "boolean" | "value" | "conflict"; + +export type ChannelSetupCliOption = { + flags: string; + negatedFlags?: string; + description: string; + defaultValue?: boolean | string; +}; + +const CHANNEL_ADD_SHARED_BOOLEAN_OPTIONS = new Set(["--help", "-h"]); +const CHANNEL_ADD_SHARED_VALUE_OPTIONS = new Set(["--channel", "--account", "--name"]); +const CHANNEL_ADD_SHARED_VALUE_OPTION_PREFIXES = ["--channel=", "--account=", "--name="]; + +const channelSetupCliOptionsLoader = createLazyImportLoader( + () => import("../channels/plugins/cli-add-options.js"), +); + +export function loadChannelSetupCliOptions(): Promise { + return channelSetupCliOptionsLoader.load(); +} + +export function getChannelSetupOptionSwitches(flags: string): string[] { + const option = new Option(flags); + return [option.short, option.long].filter((flag): flag is string => Boolean(flag)); +} + +function resolveChannelSetupFlagArity(flags: string): Exclude { + return /<[^>]+>|\[[^\]]+\]/u.test(flags) ? "value" : "boolean"; +} + +function buildChannelSetupFlagArityMap( + options: readonly ChannelSetupCliOption[], +): Map { + const arityBySwitch = new Map(); + const addSwitch = (flag: string, arity: Exclude) => { + const existing = arityBySwitch.get(flag); + arityBySwitch.set(flag, existing === undefined || existing === arity ? arity : "conflict"); + }; + for (const option of options) { + const arity = resolveChannelSetupFlagArity(option.flags); + for (const flag of getChannelSetupOptionSwitches(option.flags)) { + addSwitch(flag, arity); + } + if (option.negatedFlags) { + for (const flag of getChannelSetupOptionSwitches(option.negatedFlags)) { + addSwitch(flag, "boolean"); + } + } + } + return arityBySwitch; +} + +export async function resolveChannelsAddChannelFromArgv( + argv: string[], +): Promise { + const normalizedArgv = normalizeWindowsArgv(argv); + const addIndex = normalizedArgv.findIndex( + (arg, index) => arg === "add" && normalizedArgv[index - 1] === "channels", + ); + if (addIndex === -1) { + return undefined; + } + const args = normalizedArgv.slice(addIndex + 1); + let explicitChannel: string | undefined; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!arg || arg === "--") { + break; + } + if (arg === "--channel") { + const value = args[index + 1]?.trim(); + explicitChannel = value || explicitChannel; + index += 1; + continue; + } + if (arg.startsWith("--channel=")) { + const value = arg.slice("--channel=".length).trim(); + explicitChannel = value || explicitChannel; + } + } + if (explicitChannel) { + return explicitChannel; + } + + let channelFlagArities: Map | undefined; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!arg || arg === "--") { + break; + } + if (CHANNEL_ADD_SHARED_VALUE_OPTIONS.has(arg)) { + index += 1; + continue; + } + if (CHANNEL_ADD_SHARED_VALUE_OPTION_PREFIXES.some((prefix) => arg.startsWith(prefix))) { + continue; + } + if (CHANNEL_ADD_SHARED_BOOLEAN_OPTIONS.has(arg)) { + continue; + } + if (arg.startsWith("-")) { + // Shipped `channels add` accepted channel flags before a positional id by registering every + // channel option. Lazily inspect serialized all-channel metadata for arity only; actual + // option registration remains scoped to the selected channel. + if (!channelFlagArities) { + const { resolveChannelSetupCliOptionMetadata } = await loadChannelSetupCliOptions(); + const { optionCandidates } = resolveChannelSetupCliOptionMetadata(undefined, { + includeAll: true, + }); + channelFlagArities = buildChannelSetupFlagArityMap(optionCandidates); + } + const equalsIndex = arg.indexOf("="); + const optionSwitch = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex); + const arity = channelFlagArities.get(optionSwitch); + if (!arity || arity === "conflict") { + return undefined; + } + if (equalsIndex === -1 && arity === "value") { + index += 1; + } + continue; + } + return arg; + } + return undefined; +} + +export function resolveChannelsAddOptions( + channelArg: string | undefined, + opts: Record, + command?: Pick, +): Record { + const forwardedOpts = command + ? Object.fromEntries( + Object.entries(opts).filter(([key]) => command.getOptionValueSource(key) === "cli"), + ) + : opts; + return { + ...forwardedOpts, + channel: forwardedOpts.channel ?? channelArg, + }; +} diff --git a/src/cli/channels-cli.test.ts b/src/cli/channels-cli.test.ts index 575e4372fd21..e7830fcbe13b 100644 --- a/src/cli/channels-cli.test.ts +++ b/src/cli/channels-cli.test.ts @@ -4,6 +4,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js"; import type { PluginPackageChannel } from "../plugins/manifest.js"; import { mockProcessPlatform } from "../test-utils/vitest-spies.js"; +import { + resolveChannelsAddChannelFromArgv, + resolveChannelsAddOptions, +} from "./channels-cli-add-args.js"; import { registerChannelsCli } from "./channels-cli.js"; const listBundledPackageChannelMetadataMock = vi.hoisted(() => @@ -51,6 +55,7 @@ async function runChannelsAddCli(args: string[]) { const program = new Command().name("openclaw"); await registerChannelsCli(program, ["node", "openclaw", ...args]); await program.parseAsync(args, { from: "user" }); + return program; } describe("registerChannelsCli", () => { @@ -69,7 +74,7 @@ describe("registerChannelsCli", () => { expect(listBundledPackageChannelMetadataMock).not.toHaveBeenCalled(); expect(listRawChannelPluginCatalogEntriesMock).not.toHaveBeenCalled(); - process.argv = ["node", "openclaw", "channels", "add", "--help"]; + process.argv = ["node", "openclaw", "channels", "add", "clickclack", "--help"]; await registerChannelsCli(new Command().name("openclaw")); expect(listBundledPackageChannelMetadataMock).toHaveBeenCalledTimes(1); @@ -100,7 +105,7 @@ describe("registerChannelsCli", () => { ], }, ]); - process.argv = ["node", "openclaw", "channels", "add", "--help"]; + process.argv = ["node", "openclaw", "channels", "add", "clickclack", "--help"]; const program = new Command().name("openclaw"); await registerChannelsCli(program); @@ -132,7 +137,15 @@ describe("registerChannelsCli", () => { ]); const program = new Command().name("openclaw"); - await registerChannelsCli(program, ["node", "openclaw", "channels", "add", "--help"]); + await registerChannelsCli(program, [ + "node", + "openclaw", + "channels", + "add", + "--channel", + "installed-chat", + "--help", + ]); expect(getChannelAddOptionFlags(program)).toContain("--installed-key "); }); @@ -184,13 +197,21 @@ describe("registerChannelsCli", () => { // Commander throws on conflicting switches; registration must survive a // plugin redeclaring `--url` with a different placeholder or the static // `--token` with a different value name. - await registerChannelsCli(program, ["node", "openclaw", "channels", "add", "--help"]); + await registerChannelsCli(program, [ + "node", + "openclaw", + "channels", + "add", + "--channel", + "chat-a", + "--help", + ]); const flags = getChannelAddOptionFlags(program); expect(flags).toContain("--url "); expect(flags).not.toContain("--url "); - expect(flags).toContain("--token "); - expect(flags).not.toContain("--token "); + expect(flags).toContain("--token "); + expect(flags).not.toContain("--token "); }); it("prefers the selected channel's declaration for a shared switch", async () => { @@ -248,6 +269,190 @@ describe("registerChannelsCli", () => { expect(flags).not.toContain("--url "); }); + it("projects channel-owned setup fields into Commander options", async () => { + listBundledPackageChannelMetadataMock.mockReturnValueOnce([ + { + id: "signal", + setup: { + fields: [ + { + key: "signalTransport", + kind: "choice", + choices: ["external-native", "container"], + cli: { + flags: "--signal-transport ", + description: "Signal transport kind", + }, + }, + { + key: "autoDiscover", + kind: "boolean", + cli: { + flags: "--auto-discover", + negatedFlags: "--no-auto-discover", + description: "Discover channels automatically", + }, + }, + ], + }, + }, + ]); + process.argv = ["node", "openclaw", "channels", "add", "--channel", "signal", "--help"]; + const program = new Command().name("openclaw"); + + await registerChannelsCli(program); + + expect(getChannelAddOptionFlags(program)).toContain("--signal-transport "); + expect(getChannelAddOptionFlags(program)).toContain("--no-auto-discover"); + }); + + it("registers only the positional channel setup options", async () => { + listBundledPackageChannelMetadataMock.mockReturnValueOnce([ + { + id: "telegram", + setup: { + fields: [ + { + key: "token", + kind: "string", + cli: { flags: "--telegram-token ", description: "Telegram bot token" }, + }, + ], + }, + }, + { + id: "signal", + setup: { + fields: [ + { + key: "signalNumber", + kind: "string", + cli: { flags: "--signal-number ", description: "Signal account number" }, + }, + ], + }, + }, + ]); + const program = new Command().name("openclaw"); + + await registerChannelsCli(program, [ + "node", + "openclaw", + "channels", + "add", + "telegram", + "--help", + ]); + + expect(getChannelAddOptionFlags(program)).toEqual( + expect.arrayContaining(["--telegram-token "]), + ); + expect(getChannelAddOptionFlags(program)).not.toEqual( + expect.arrayContaining(["--signal-number "]), + ); + }); + + it.each(["--help", "-h"])( + "keeps generic add help via %s limited to the shared control envelope", + async (helpFlag) => { + const program = new Command().name("openclaw"); + + await registerChannelsCli(program, ["node", "openclaw", "channels", "add", helpFlag]); + + expect(getChannelAddOptionFlags(program)).toEqual([ + "--channel ", + "--account ", + "--name ", + ]); + expect(listBundledPackageChannelMetadataMock).not.toHaveBeenCalled(); + }, + ); + + it("registers add help when channels reuse a long flag with different placeholders", async () => { + listBundledPackageChannelMetadataMock.mockReturnValueOnce([ + { + id: "example", + setup: { + fields: [ + { + key: "apiKey", + kind: "string", + cli: { flags: "--api-key ", description: "Example API key" }, + }, + { + key: "apiKeyJson", + kind: "string", + cli: { flags: "--api-key ", description: "Example API key JSON" }, + }, + ], + }, + }, + ]); + process.argv = ["node", "openclaw", "channels", "add", "example", "--help"]; + const program = new Command().name("openclaw"); + + await registerChannelsCli(program); + + expect( + getChannelAddOptionFlags(program).filter((flags) => flags.startsWith("--api-key ")), + ).toHaveLength(1); + }); + + it("forwards only explicitly supplied setup options", () => { + const sources = new Map([ + ["channel", "cli"], + ["signalTransport", "cli"], + ["useEnv", "default"], + ]); + + expect( + resolveChannelsAddOptions( + undefined, + { channel: "signal", signalTransport: "container", useEnv: false }, + { + getOptionValueSource: (key) => sources.get(key), + } as Pick, + ), + ).toEqual({ channel: "signal", signalTransport: "container" }); + }); + + it("preserves selected legacy channel defaults", async () => { + listBundledPackageChannelMetadataMock.mockReturnValueOnce([ + { + id: "legacy-chat", + cliAddOptions: [ + { + flags: "--legacy-mode ", + description: "Legacy transport mode", + defaultValue: "socket", + }, + ], + }, + ]); + + const program = await runChannelsAddCli([ + "channels", + "add", + "--channel", + "legacy-chat", + "--token", + "test-token", + ]); + + expect(channelsAddCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "legacy-chat", + legacyMode: "socket", + token: "test-token", + useEnv: false, + }), + runtimeMock, + { hasFlags: true }, + ); + expect(getChannelAddOptionFlags(program)).not.toContain("--secret-file "); + expect(getChannelAddOptionFlags(program)).not.toContain("--workspace "); + }); + it("uses caller argv instead of raw process argv for channel-specific add options", async () => { process.argv = ["node", "openclaw", "channels"]; @@ -256,6 +461,7 @@ describe("registerChannelsCli", () => { "openclaw", "channels", "add", + "telegram", "--help", ]); @@ -333,6 +539,243 @@ describe("registerChannelsCli", () => { ); }); + it("registers selected-channel options before Commander parses option-first argv", async () => { + listBundledPackageChannelMetadataMock.mockReturnValueOnce([ + { + id: "telegram", + setup: { + fields: [ + { + key: "token", + kind: "string", + cli: { flags: "--token ", description: "Telegram bot token" }, + }, + ], + }, + }, + ]); + + await runChannelsAddCli(["channels", "add", "--token", "test-token", "--channel", "telegram"]); + + expect(channelsAddCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "telegram", token: "test-token" }), + runtimeMock, + { hasFlags: true }, + ); + }); + + it("prefers modern contract options when a channel also publishes cliAddOptions", async () => { + listBundledPackageChannelMetadataMock.mockReturnValue([ + { + id: "telegram", + setup: { + fields: [ + { + key: "token", + kind: "string", + cli: { flags: "--token ", description: "Telegram bot token" }, + }, + ], + }, + cliAddOptions: [{ flags: "--legacy-token ", description: "Retained legacy switch" }], + }, + ]); + + const program = new Command().name("openclaw"); + const argv = ["channels", "add", "telegram", "--token", "test-token"]; + await registerChannelsCli(program, ["node", "openclaw", ...argv]); + const flags = getChannelAddOptionFlags(program); + expect(flags).toContain("--token "); + expect(flags).not.toContain("--legacy-token "); + + await program.parseAsync(argv, { from: "user" }); + expect(channelsAddCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "telegram", token: "test-token" }), + runtimeMock, + { hasFlags: true }, + ); + }); + + it("resolves a positional channel after a value-taking channel option", async () => { + const metadata: PluginPackageChannel[] = [ + { + id: "telegram", + setup: { + fields: [ + { + key: "token", + kind: "string", + cli: { flags: "--token ", description: "Telegram bot token" }, + }, + ], + }, + }, + ]; + listBundledPackageChannelMetadataMock + .mockReturnValueOnce(metadata) + .mockReturnValueOnce(metadata); + + await runChannelsAddCli(["channels", "add", "--token", "tok", "telegram"]); + + expect(channelsAddCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "telegram", token: "tok" }), + runtimeMock, + { hasFlags: true }, + ); + }); + + it("resolves a positional channel after a boolean channel option", async () => { + const metadata: PluginPackageChannel[] = [ + { + id: "telegram", + setup: { + fields: [ + { + key: "useEnv", + kind: "boolean", + cli: { flags: "--use-env", description: "Use Telegram environment credentials" }, + }, + ], + }, + }, + ]; + listBundledPackageChannelMetadataMock + .mockReturnValueOnce(metadata) + .mockReturnValueOnce(metadata); + + await runChannelsAddCli(["channels", "add", "--use-env", "telegram"]); + + expect(channelsAddCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "telegram", useEnv: true }), + runtimeMock, + { hasFlags: true }, + ); + }); + + it("keeps an all-channel-unknown flag before a positional channel ambiguous", async () => { + await expect( + resolveChannelsAddChannelFromArgv([ + "node", + "openclaw", + "channels", + "add", + "--unknown-option", + "value", + "telegram", + ]), + ).resolves.toBeUndefined(); + }); + + it("keeps conflicting all-channel flag arities before a positional channel ambiguous", async () => { + listBundledPackageChannelMetadataMock.mockReturnValueOnce([ + { + id: "chat-a", + setup: { + fields: [ + { + key: "mode", + kind: "string", + cli: { flags: "--mode ", description: "Chat A mode" }, + }, + ], + }, + }, + { + id: "chat-b", + setup: { + fields: [ + { + key: "mode", + kind: "boolean", + cli: { flags: "--mode", description: "Enable Chat B mode" }, + }, + ], + }, + }, + ]); + + await expect( + resolveChannelsAddChannelFromArgv([ + "node", + "openclaw", + "channels", + "add", + "--mode", + "telegram", + ]), + ).resolves.toBeUndefined(); + }); + + it("finds a positional channel after shared option-value pairs", async () => { + listBundledPackageChannelMetadataMock.mockReturnValueOnce([ + { + id: "telegram", + setup: { + fields: [ + { + key: "token", + kind: "string", + cli: { flags: "--token ", description: "Telegram bot token" }, + }, + ], + }, + }, + ]); + + await runChannelsAddCli(["channels", "add", "--account", "work", "telegram", "--token", "tok"]); + + expect(channelsAddCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "telegram", account: "work", token: "tok" }), + runtimeMock, + { hasFlags: true }, + ); + }); + + it("lets an explicit channel override the positional channel during option registration", async () => { + listBundledPackageChannelMetadataMock.mockReturnValueOnce([ + { + id: "telegram", + setup: { + fields: [ + { + key: "token", + kind: "string", + cli: { flags: "--token ", description: "Telegram bot token" }, + }, + ], + }, + }, + { + id: "signal", + setup: { + fields: [ + { + key: "signalNumber", + kind: "string", + cli: { flags: "--signal-number ", description: "Signal account number" }, + }, + ], + }, + }, + ]); + + await runChannelsAddCli([ + "channels", + "add", + "telegram", + "--channel", + "signal", + "--signal-number", + "+15555550123", + ]); + + expect(channelsAddCommandMock).toHaveBeenCalledWith( + expect.objectContaining({ channel: "signal", signalNumber: "+15555550123" }), + runtimeMock, + { hasFlags: true }, + ); + }); + it("treats plugin-provided config flags as direct automation inputs", async () => { listBundledPackageChannelMetadataMock.mockReturnValueOnce([ { diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index 471e705e979e..3181e59760b5 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -8,6 +8,13 @@ import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { resolveCliArgvInvocation } from "./argv-invocation.js"; import { runChannelLogin, runChannelLogout } from "./channel-auth.js"; import { formatCliChannelOptions } from "./channel-options.js"; +import { + getChannelSetupOptionSwitches, + loadChannelSetupCliOptions, + resolveChannelsAddChannelFromArgv, + resolveChannelsAddOptions, + type ChannelSetupCliOption, +} from "./channels-cli-add-args.js"; import { runCommandWithRuntime } from "./cli-utils.js"; import { hasExplicitOptions } from "./command-options.js"; import { formatHelpExamples } from "./help-format.js"; @@ -15,8 +22,6 @@ import { applyParentDefaultHelpAction } from "./program/parent-default-help.js"; import { normalizeWindowsArgv } from "./windows-argv.js"; type ChannelsCommandsModule = typeof import("../commands/channels.js"); -type ChannelSetupCliOptionsModule = typeof import("../channels/plugins/cli-add-options.js"); - const optionNamesRemove = ["channel", "account", "delete"] as const; const CHANNEL_ADD_SELECTION_OPTION_NAMES = new Set(["channel"]); @@ -24,13 +29,37 @@ type RegisterChannelsCliOptions = { includeSetupOptions?: boolean; }; +type AddChannelSetupOptionsParams = { + channelId?: string; + includeAll?: boolean; +}; + +type ChannelSetupOptionMode = "none" | "modern" | "legacy"; +const LEGACY_CHANNEL_SETUP_OPTIONS: readonly ChannelSetupCliOption[] = [ + { flags: "--token ", description: "Channel token or credential payload" }, + { + flags: "--token-file ", + description: "Read channel token or credential payload from file", + }, + { flags: "--secret ", description: "Channel shared secret" }, + { flags: "--bot-token ", description: "Bot token" }, + { flags: "--app-token ", description: "App token" }, + { flags: "--password ", description: "Channel password or login secret" }, + { flags: "--cli-path ", description: "Channel CLI path" }, + { flags: "--url ", description: "Channel setup URL" }, + { flags: "--base-url ", description: "Channel base URL" }, + { flags: "--http-url ", description: "Channel HTTP service URL" }, + { flags: "--auth-dir ", description: "Channel auth directory override" }, + { + flags: "--use-env", + description: "Use env-backed credentials when supported", + defaultValue: false, + }, +]; + const channelsCommandsLoader = createLazyImportLoader( () => import("../commands/channels.js"), ); -const channelSetupCliOptionsLoader = createLazyImportLoader( - () => import("../channels/plugins/cli-add-options.js"), -); - function loadChannelsCommands(): Promise { return channelsCommandsLoader.load(); } @@ -50,14 +79,28 @@ function getOptionNames(command: Command): string[] { return command.options.map((option) => option.attributeName()); } -function resolveChannelsAddOptions( - channelArg: string | undefined, - opts: Record, -): Record { - return { - ...opts, - channel: opts.channel ?? channelArg, - }; +function addChannelSetupOption( + command: Command, + option: ChannelSetupCliOption, + seenFlags: Set, +): void { + const optionSwitches = getChannelSetupOptionSwitches(option.flags); + if (optionSwitches.some((flag) => seenFlags.has(flag))) { + return; + } + optionSwitches.forEach((flag) => seenFlags.add(flag)); + if (option.defaultValue !== undefined) { + command.option(option.flags, option.description, option.defaultValue); + } else { + command.option(option.flags, option.description); + } + if (option.negatedFlags) { + const negatedSwitches = getChannelSetupOptionSwitches(option.negatedFlags); + if (!negatedSwitches.some((flag) => seenFlags.has(flag))) { + negatedSwitches.forEach((flag) => seenFlags.add(flag)); + command.option(option.negatedFlags, option.description); + } + } } function shouldRegisterChannelSetupOptions( @@ -72,52 +115,35 @@ function shouldRegisterChannelSetupOptions( return commandPath[0] === "channels" && commandPath[1] === "add"; } -// Best-effort pre-parse sniff of the selected channel so its option -// declarations win registration. Misses only the unusual `add --flag value -// ` shape, which falls back to first-declaration ordering. -function resolveChannelsAddArgvChannel(argv: string[]): string | undefined { - const tokens = normalizeWindowsArgv(argv); - const addIndex = tokens.indexOf("add"); - if (addIndex === -1) { - return undefined; - } - const rest = tokens.slice(addIndex + 1); - const channelFlagIndex = rest.indexOf("--channel"); - if (channelFlagIndex !== -1) { - const value = rest[channelFlagIndex + 1]; - return value && !value.startsWith("-") ? value : undefined; - } - const inline = rest.find((token) => token.startsWith("--channel=")); - if (inline) { - return inline.slice("--channel=".length) || undefined; - } - const positional = rest[0]; - return positional && !positional.startsWith("-") ? positional : undefined; -} - -async function addChannelSetupOptions(command: Command, channelId?: string): Promise { - const { channelCliOptionSwitchKey, resolveChannelSetupCliOptionMetadata } = - await channelSetupCliOptionsLoader.load(); - // Seed with switch identities, not raw flags strings: Commander throws on a - // matching switch with a different placeholder (e.g. plugin `--token ` - // vs the static `--token `). - const seenSwitches = new Set( - command.options.map((option) => option.long ?? option.short ?? option.flags), +async function addChannelSetupOptions( + command: Command, + params: AddChannelSetupOptionsParams = {}, +): Promise { + const { resolveChannelSetupCliOptionMetadata } = await loadChannelSetupCliOptions(); + const selected = params.channelId?.trim().toLowerCase(); + const { options, selectedChannel } = resolveChannelSetupCliOptionMetadata(selected, { + includeAll: params.includeAll, + }); + const mode: ChannelSetupOptionMode = selected + ? selectedChannel?.setup + ? "modern" + : "legacy" + : "none"; + const seenFlags = new Set( + command.options.flatMap((option) => getChannelSetupOptionSwitches(option.flags)), ); - const { options } = resolveChannelSetupCliOptionMetadata(channelId); for (const option of options) { - const key = channelCliOptionSwitchKey(option.flags); - if (seenSwitches.has(key)) { - continue; - } - seenSwitches.add(key); - if (option.defaultValue !== undefined) { - command.option(option.flags, option.description, option.defaultValue); - } else { - command.option(option.flags, option.description); + addChannelSetupOption(command, option, seenFlags); + } + if ( + params.includeAll || + (mode === "legacy" && (selectedChannel === undefined || selectedChannel.setup === undefined)) + ) { + for (const option of LEGACY_CHANNEL_SETUP_OPTIONS) { + addChannelSetupOption(command, option, seenFlags); } } - return command; + return mode; } export async function registerChannelsCli( @@ -280,13 +306,18 @@ export async function registerChannelsCli( ) .option("--channel ", `Channel (${channelNames})`) .option("--account ", "Account id (default when omitted)") - .option("--name ", "Display name for this account") - .option("--token ", "Channel token or credential payload") - .option("--token-file ", "Read channel token or credential payload from file") - .option("--use-env", "Use env-backed credentials when supported", false); + .option("--name ", "Display name for this account"); - if (shouldRegisterChannelSetupOptions(argv, options)) { - await addChannelSetupOptions(addCommand, resolveChannelsAddArgvChannel(argv)); + let channelSetupOptionMode: ChannelSetupOptionMode = "none"; + const selectedChannelId = await resolveChannelsAddChannelFromArgv(argv); + if ( + shouldRegisterChannelSetupOptions(argv, options) && + (selectedChannelId !== undefined || options.includeSetupOptions) + ) { + channelSetupOptionMode = await addChannelSetupOptions(addCommand, { + channelId: selectedChannelId, + includeAll: options.includeSetupOptions, + }); } addCommand.action(async (channelArg: string | undefined, opts, command) => { @@ -296,9 +327,17 @@ export async function registerChannelsCli( command, getOptionNames(command).filter((name) => !CHANNEL_ADD_SELECTION_OPTION_NAMES.has(name)), ); - await channelsAddCommand(resolveChannelsAddOptions(channelArg, opts), defaultRuntime, { - hasFlags, - }); + await channelsAddCommand( + resolveChannelsAddOptions( + channelArg, + opts, + channelSetupOptionMode === "modern" ? command : undefined, + ), + defaultRuntime, + { + hasFlags, + }, + ); }); }); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 3ecd0576a066..e876059f3d3e 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -117,6 +117,14 @@ vi.mock("../infra/openclaw-root.js", () => ({ resolveOpenClawPackageRootSync: vi.fn(() => process.cwd()), })); +vi.mock("../daemon/gateway-entrypoint.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveGatewayInstallEntrypoint: vi.fn(actual.resolveGatewayInstallEntrypoint), + }; +}); + vi.mock("../config/config.js", () => ({ assertConfigWriteAllowedInCurrentMode: () => { if (process.env.OPENCLAW_NIX_MODE === "1") { @@ -419,6 +427,7 @@ vi.mock("../runtime.js", () => ({ const { runGatewayUpdate } = await import("../infra/update-runner.js"); const { resolveOpenClawPackageRoot } = await import("../infra/openclaw-root.js"); +const { resolveGatewayInstallEntrypoint } = await import("../daemon/gateway-entrypoint.js"); const { mutateConfigFileWithRetry, readConfigFileSnapshot, @@ -439,6 +448,8 @@ const { runDaemonRestart, runDaemonInstall } = await import("./daemon-cli.js"); const { doctorCommand } = await import("../commands/doctor.js"); const { defaultRuntime } = await import("../runtime.js"); const postCorePluginConvergence = await import("./update-cli/post-core-plugin-convergence.js"); +const { completePostCorePluginUpdate } = + await import("./update-cli/update-command-fresh-doctor.js"); const runPostCorePluginConvergenceSpy = vi.spyOn( postCorePluginConvergence, "runPostCorePluginConvergence", @@ -884,6 +895,32 @@ describe("update-cli", () => { return { root, entrypoints }; }; + const FRESH_POST_UPDATE_ENTRYPOINT = "/tmp/openclaw-updated-entry.mjs"; + + const mockCurrentProcessFreshDoctor = (params: { postCoreResumeAttempt?: boolean } = {}) => { + if (params.postCoreResumeAttempt !== false) { + vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce(undefined); + } + vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce(FRESH_POST_UPDATE_ENTRYPOINT); + }; + + const expectFreshPostUpdateDoctor = (params: { yes: boolean }) => { + const calls = vi + .mocked(runExec) + .mock.calls.filter( + ([, args]) => args[0] === FRESH_POST_UPDATE_ENTRYPOINT && args[1] === "doctor", + ); + expect(calls).toHaveLength(1); + expect(calls[0]?.[1]).toEqual([ + FRESH_POST_UPDATE_ENTRYPOINT, + "doctor", + "--repair", + "--non-interactive", + "--no-workspace-suggestions", + ...(params.yes ? ["--yes"] : []), + ]); + }; + beforeEach(() => { delete process.env.OPENCLAW_SERVICE_MARKER; delete process.env.OPENCLAW_SERVICE_KIND; @@ -1486,6 +1523,7 @@ describe("update-cli", () => { tag: "latest", version: "2026.4.10", }); + mockCurrentProcessFreshDoctor(); probeGateway.mockResolvedValue({ ok: true, close: null, @@ -1508,10 +1546,35 @@ describe("update-cli", () => { expect(spawn).not.toHaveBeenCalled(); expect(syncPluginsForUpdateChannel).toHaveBeenCalledTimes(1); expect(updateNpmInstalledPlugins).toHaveBeenCalledTimes(1); + expectFreshPostUpdateDoctor({ yes: true }); expectNoSideEffects(runDaemonInstall, probeGateway); expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); }); + it("runs the fresh doctor for a core-changing downgrade without plugin changes", async () => { + const downgradedRoot = createCaseDir("openclaw-downgraded-fresh-doctor-root"); + setupUpdatedRootRefresh({ + gatewayUpdateImpl: async () => + makeOkUpdateResult({ + mode: "npm", + root: downgradedRoot, + before: { version: "2026.4.14" }, + after: { version: "2026.4.10" }, + }), + }); + readPackageVersion.mockResolvedValue("2026.4.14"); + vi.mocked(resolveNpmChannelTag).mockResolvedValue({ tag: "latest", version: "2026.4.10" }); + mockCurrentProcessFreshDoctor(); + + await updateCommand({ yes: true, tag: "2026.4.10", restart: false }); + + expect(spawn).not.toHaveBeenCalled(); + expect(syncPluginsForUpdateChannel).toHaveBeenCalledTimes(1); + expect(updateNpmInstalledPlugins).toHaveBeenCalledTimes(1); + expectFreshPostUpdateDoctor({ yes: true }); + expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); + }); + it("pins the compatibility host version to the downgraded target during current-process post-core plugin convergence (#87914)", async () => { const downgradedRoot = createCaseDir("openclaw-downgraded-compat-root"); setupUpdatedRootRefresh({ @@ -1553,6 +1616,215 @@ describe("update-cli", () => { } }); + it("runs updated plugin migrations for a plugin-only current-process update", async () => { + mockGitUpdateAfterMutation(); + vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce( + "/tmp/openclaw-updated-entry.mjs", + ); + updateNpmInstalledPlugins.mockResolvedValueOnce({ + changed: true, + config: baseConfig, + outcomes: [], + }); + let strictValidationEnv: string | undefined; + vi.mocked(readConfigFileSnapshot).mockImplementation(async (options) => { + if (!options) { + strictValidationEnv = process.env.OPENCLAW_UPDATE_IN_PROGRESS; + } + return baseSnapshot; + }); + vi.mocked(runExec).mockImplementationOnce(async (_file, args) => { + expect(args).toEqual([ + "/tmp/openclaw-updated-entry.mjs", + "doctor", + "--repair", + "--non-interactive", + "--no-workspace-suggestions", + "--yes", + ]); + return { stdout: "", stderr: "" }; + }); + + await updateCommand({ yes: true, restart: false }); + + expect(spawn).not.toHaveBeenCalled(); + expect(resolveGatewayInstallEntrypoint).toHaveBeenCalledTimes(1); + expect(runExec).toHaveBeenCalledTimes(2); + expect(strictValidationEnv).toBe("0"); + expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); + }); + + it("runs the fresh plugin doctor with the selected Node runner", async () => { + vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce( + "/tmp/openclaw-updated-entry.mjs", + ); + await completePostCorePluginUpdate({ + root: "/tmp/openclaw-updated-root", + pluginUpdate: { + status: "ok", + changed: true, + warnings: [], + sync: { + changed: false, + switchedToBundled: [], + switchedToNpm: [], + warnings: [], + errors: [], + }, + npm: { changed: true, outcomes: [] }, + integrityDrifts: [], + }, + freshDoctorRequired: true, + yes: true, + json: true, + timeoutMs: 30_000, + nodeRunner: "/opt/openclaw-service/bin/node", + }); + + expect(vi.mocked(runExec).mock.calls[0]?.[0]).toBe("/opt/openclaw-service/bin/node"); + }); + + it("runs the fresh plugin doctor when the migration owner changed even if config is valid", async () => { + vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce( + "/tmp/openclaw-updated-entry.mjs", + ); + const result = await completePostCorePluginUpdate({ + root: "/tmp/openclaw-updated-root", + pluginUpdate: { + status: "ok", + changed: true, + warnings: [], + sync: { + changed: false, + switchedToBundled: [], + switchedToNpm: [], + warnings: [], + errors: [], + }, + npm: { changed: true, outcomes: [] }, + integrityDrifts: [], + }, + freshDoctorRequired: true, + yes: true, + json: true, + timeoutMs: 30_000, + }); + + expect(result.pluginUpdate.status).toBe("ok"); + expect(runExec).toHaveBeenCalledTimes(2); + expect(resolveGatewayInstallEntrypoint).toHaveBeenCalledTimes(1); + }); + + it("returns a structured error when the fresh plugin doctor cannot run", async () => { + vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce( + "/tmp/openclaw-updated-entry.mjs", + ); + vi.mocked(runExec).mockRejectedValueOnce(new Error("doctor process failed")); + const result = await completePostCorePluginUpdate({ + root: "/tmp/openclaw-updated-root", + pluginUpdate: { + status: "ok", + changed: true, + warnings: [], + sync: { + changed: false, + switchedToBundled: [], + switchedToNpm: [], + warnings: [], + errors: [], + }, + npm: { changed: true, outcomes: [] }, + integrityDrifts: [], + }, + freshDoctorRequired: true, + yes: true, + json: true, + timeoutMs: 30_000, + }); + + expect(result.pluginUpdate).toMatchObject({ + status: "error", + reason: "post-plugin-doctor-execution-failed", + }); + expect(result.pluginUpdate.warnings?.at(-1)?.reason).toContain("doctor process failed"); + }); + + it("keeps an invalid config authoritative after a fresh plugin doctor failure", async () => { + vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce( + "/tmp/openclaw-updated-entry.mjs", + ); + vi.mocked(runExec) + .mockRejectedValueOnce(new Error("doctor process failed")) + .mockRejectedValueOnce(new Error("config invalid")); + vi.mocked(readConfigFileSnapshot).mockResolvedValueOnce({ + ...baseSnapshot, + valid: false, + issues: [{ path: "channels.signal.httpUrl", message: "legacy Signal transport field" }], + } as ConfigFileSnapshot); + + const result = await completePostCorePluginUpdate({ + root: "/tmp/openclaw-updated-root", + pluginUpdate: { + status: "ok", + changed: true, + warnings: [], + sync: { + changed: false, + switchedToBundled: [], + switchedToNpm: [], + warnings: [], + errors: [], + }, + npm: { changed: true, outcomes: [] }, + integrityDrifts: [], + }, + freshDoctorRequired: true, + yes: true, + json: true, + timeoutMs: 30_000, + }); + + expect(result.pluginUpdate).toMatchObject({ + status: "error", + reason: "post-plugin-doctor-invalid-config", + }); + }); + + it("keeps entrypoint resolution failures structured and fail-closed", async () => { + vi.mocked(resolveGatewayInstallEntrypoint).mockRejectedValueOnce( + new Error("entrypoint lookup failed"), + ); + + const result = await completePostCorePluginUpdate({ + root: "/tmp/openclaw-updated-root", + pluginUpdate: { + status: "ok", + changed: true, + warnings: [], + sync: { + changed: false, + switchedToBundled: [], + switchedToNpm: [], + warnings: [], + errors: [], + }, + npm: { changed: true, outcomes: [] }, + integrityDrifts: [], + }, + freshDoctorRequired: true, + yes: true, + json: true, + timeoutMs: 30_000, + }); + + expect(result.pluginUpdate).toMatchObject({ + status: "error", + reason: "post-plugin-doctor-invalid-config", + }); + expect(result.pluginUpdate.warnings?.[0]?.reason).toContain("entrypoint lookup failed"); + expect(runExec).not.toHaveBeenCalled(); + }); + it("fails the update when the fresh process exits non-zero", async () => { setupUpdatedRootRefresh(); spawn.mockImplementationOnce(() => { @@ -2023,6 +2295,9 @@ describe("update-cli", () => { }); it("includes colored ClawHub trust warnings in json post-core plugin output", async () => { + vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce( + "/tmp/openclaw-updated-entry.mjs", + ); const trustWarning = "╭─ WARNING - ClawHub found security risks in this release ─╮\n" + "│ • Security scan: suspicious │\n" + @@ -2236,6 +2511,9 @@ describe("update-cli", () => { }); it("marks disabled-after-failure plugin skips as post-update warnings", async () => { + vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce( + "/tmp/openclaw-updated-entry.mjs", + ); updateNpmInstalledPlugins.mockResolvedValueOnce({ changed: true, config: baseConfig, @@ -3118,12 +3396,14 @@ describe("update-cli", () => { tag: "latest", version: null, }); + mockCurrentProcessFreshDoctor(); await updateCommand({}); expect(getErrorOutput()).not.toContain("Downgrade confirmation required."); expect(defaultRuntime.exit).not.toHaveBeenCalled(); expectPackageInstallSpec("openclaw@latest"); + expectFreshPostUpdateDoctor({ yes: false }); }); it("blocks the package update when a non-latest dist-tag lookup is unresolved", async () => { @@ -3147,6 +3427,7 @@ describe("update-cli", () => { it("warns but still runs package updates when disk space looks low", async () => { const tempDir = createCaseDir("openclaw-update"); mockPackageInstallStatus(tempDir); + mockCurrentProcessFreshDoctor(); vi.spyOn(fsSync, "statfsSync").mockReturnValue( statfsFixture({ bavail: 256, @@ -4574,6 +4855,38 @@ describe("update-cli", () => { expect(defaultRuntime.exit).toHaveBeenCalledWith(1); }); + it("restarts a stopped git service when the fresh plugin doctor cannot run", async () => { + const serviceEntrypoint = path.join(process.cwd(), "dist", "index.js"); + serviceReadCommand.mockResolvedValue({ + programArguments: ["node", serviceEntrypoint, "gateway", "run"], + environment: { + OPENCLAW_SERVICE_MARKER: "openclaw", + OPENCLAW_SERVICE_KIND: "gateway", + }, + }); + serviceLoaded.mockResolvedValue(true); + serviceReadRuntime.mockResolvedValue({ + status: "running", + pid: 4242, + state: "running", + }); + mockGitUpdateAfterMutation(); + updateNpmInstalledPlugins.mockResolvedValueOnce({ + changed: true, + config: baseConfig, + outcomes: [], + }); + vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce( + "/tmp/openclaw-updated-entry.mjs", + ); + vi.mocked(runExec).mockRejectedValueOnce(new Error("doctor process failed")); + await updateCommand({ yes: true }); + + expect(serviceStop).toHaveBeenCalledTimes(1); + expect(serviceRestart).toHaveBeenCalledTimes(1); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + }); + it("keeps managed service stop output off stdout during json package updates", async () => { const tempDir = await createTrackedTempDir("openclaw-update-json-stop-service-"); const nodeModules = path.join(tempDir, "node_modules"); @@ -4787,6 +5100,7 @@ describe("update-cli", () => { const nodeModules = path.join(tempDir, "node_modules"); const pkgRoot = path.join(nodeModules, "openclaw"); mockPackageInstallStatus(pkgRoot); + mockCurrentProcessFreshDoctor(); await fs.mkdir(pkgRoot, { recursive: true }); await fs.writeFile( path.join(pkgRoot, "package.json"), @@ -5522,6 +5836,7 @@ describe("update-cli", () => { it("repairs legacy config before persisting a requested update channel", async () => { const tempDir = createCaseDir("openclaw-update"); mockPackageInstallStatus(tempDir); + mockCurrentProcessFreshDoctor(); const legacyConfig = { channels: { slack: { @@ -6953,6 +7268,7 @@ describe("update-cli", () => { it("restores an unknown package service without rewriting its missing updated entrypoint", async () => { const tempDir = createCaseDir("openclaw-update"); mockPackageInstallStatus(tempDir); + mockCurrentProcessFreshDoctor(); serviceLoaded.mockResolvedValue(true); vi.mocked(runDaemonInstall).mockRejectedValueOnce(new Error("refresh failed")); @@ -7713,6 +8029,7 @@ describe("update-cli", () => { }); it("updateFinalizeCommand repairs doctor by default and refreshes plugin state after doctor", async () => { + vi.mocked(resolveGatewayInstallEntrypoint).mockResolvedValueOnce("/tmp/openclaw-entry.mjs"); const preDoctorConfig = { update: { channel: "stable" }, plugins: { entries: { pre: { enabled: true } } }, @@ -7745,6 +8062,7 @@ describe("update-cli", () => { } satisfies Record; vi.mocked(readConfigFileSnapshot) .mockResolvedValueOnce(preDoctorSnapshot) + .mockResolvedValueOnce(postDoctorSnapshot) .mockResolvedValueOnce(postDoctorSnapshot); loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(postDoctorRecords); syncPluginsForUpdateChannel.mockImplementationOnce( @@ -7768,6 +8086,25 @@ describe("update-cli", () => { repair: true, yes: false, }); + expect(doctorCommand).toHaveBeenCalledTimes(1); + const freshDoctorCall = vi + .mocked(runExec) + .mock.calls.find(([, args]) => args.includes("doctor")); + expect(freshDoctorCall?.[1]).toEqual([ + "/tmp/openclaw-entry.mjs", + "doctor", + "--repair", + "--non-interactive", + "--no-workspace-suggestions", + ]); + expect(freshDoctorCall?.[2]).toMatchObject({ + cwd: process.cwd(), + env: { + OPENCLAW_UPDATE_IN_PROGRESS: "1", + OPENCLAW_UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR: "1", + OPENCLAW_UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE: "1", + }, + }); expect(syncPluginCall()?.channel).toBe("beta"); expect(syncPluginCall()?.config).toEqual({ ...postDoctorConfig, @@ -7957,6 +8294,9 @@ describe("update-cli", () => { }, ])("$name in non-interactive mode", async ({ options, shouldExit, shouldRunPackageUpdate }) => { await setupNonInteractiveDowngrade(); + if (shouldRunPackageUpdate) { + mockCurrentProcessFreshDoctor({ postCoreResumeAttempt: false }); + } await updateCommand(options); const downgradeMessageSeen = vi diff --git a/src/cli/update-cli/update-command-fresh-doctor.ts b/src/cli/update-cli/update-command-fresh-doctor.ts new file mode 100644 index 000000000000..1d270310291b --- /dev/null +++ b/src/cli/update-cli/update-command-fresh-doctor.ts @@ -0,0 +1,228 @@ +// Runs the post-plugin migration pass without retaining pre-update plugin modules. +import { + UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV, + UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV, +} from "../../commands/doctor/shared/update-phase.js"; +import { readConfigFileSnapshot } from "../../config/config.js"; +import type { ConfigFileSnapshot } from "../../config/types.openclaw.js"; +import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js"; +import { runExec } from "../../process/exec.js"; +import { defaultRuntime } from "../../runtime.js"; +import { resolveNodeRunner } from "./shared.js"; +import type { PostCorePluginUpdateResult } from "./update-command-plugins.js"; +import { + applyPostPluginConfigValidation, + POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON, +} from "./update-command-post-plugin-validation.js"; +import { + disableUpdatedPackageCompileCacheEnv, + stripGatewayServiceMarkerEnv, +} from "./update-command-service.js"; + +export function withUpdateFinalizationEnv(run: () => Promise): Promise { + const previousUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS; + const previousDeferConfiguredPluginInstallRepair = + process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]; + const previousParentSupportsDoctorConfigWrite = + process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]; + process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1"; + process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV] = "1"; + process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] = "1"; + return run().finally(() => { + if (previousUpdateInProgress === undefined) { + delete process.env.OPENCLAW_UPDATE_IN_PROGRESS; + } else { + process.env.OPENCLAW_UPDATE_IN_PROGRESS = previousUpdateInProgress; + } + if (previousDeferConfiguredPluginInstallRepair === undefined) { + delete process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]; + } else { + process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV] = + previousDeferConfiguredPluginInstallRepair; + } + if (previousParentSupportsDoctorConfigWrite === undefined) { + delete process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]; + } else { + process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] = + previousParentSupportsDoctorConfigWrite; + } + }); +} + +async function withNormalConfigValidation(run: () => Promise): Promise { + const previousUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS; + process.env.OPENCLAW_UPDATE_IN_PROGRESS = "0"; + try { + return await run(); + } finally { + if (previousUpdateInProgress === undefined) { + delete process.env.OPENCLAW_UPDATE_IN_PROGRESS; + } else { + process.env.OPENCLAW_UPDATE_IN_PROGRESS = previousUpdateInProgress; + } + } +} + +function createPostPluginDoctorExecutionFailure( + pluginUpdate: PostCorePluginUpdateResult, + reason: string, +): PostCorePluginUpdateResult { + return { + ...pluginUpdate, + status: "error", + reason: POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON, + warnings: [ + ...(pluginUpdate.warnings ?? []), + { + reason, + message: "Updated plugin migrations could not be run in a fresh process.", + guidance: ["Run `openclaw update repair` to retry post-update plugin repair."], + }, + ], + }; +} + +async function runPostPluginDoctorInFreshProcess(params: { + root: string; + yes: boolean; + json: boolean; + timeoutMs: number; + nodeRunner?: string; + entryPath?: string; +}): Promise { + const entryPath = params.entryPath ?? (await resolveGatewayInstallEntrypoint(params.root)); + if (!entryPath) { + throw new Error("Updated OpenClaw entrypoint not found for post-plugin doctor"); + } + const args = [ + entryPath, + "doctor", + "--repair", + "--non-interactive", + "--no-workspace-suggestions", + ...(params.yes ? ["--yes"] : []), + ]; + const result = await runExec(params.nodeRunner ?? resolveNodeRunner(), args, { + cwd: params.root, + timeoutMs: params.timeoutMs, + maxBuffer: 4 * 1024 * 1024, + logOutput: false, + baseEnv: stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)), + env: { + OPENCLAW_UPDATE_IN_PROGRESS: "1", + [UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1", + [UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1", + }, + }); + if (!params.json) { + if (result.stdout.trim()) { + defaultRuntime.log(result.stdout.trimEnd()); + } + if (result.stderr.trim()) { + defaultRuntime.error(result.stderr.trimEnd()); + } + } +} + +async function validatePostPluginConfigInFreshProcess(params: { + root: string; + timeoutMs: number; + entryPath: string; + nodeRunner?: string; +}): Promise { + try { + await runExec( + params.nodeRunner ?? resolveNodeRunner(), + [params.entryPath, "config", "validate", "--json"], + { + cwd: params.root, + timeoutMs: params.timeoutMs, + maxBuffer: 4 * 1024 * 1024, + logOutput: false, + baseEnv: stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)), + env: { OPENCLAW_UPDATE_IN_PROGRESS: "0" }, + }, + ); + return true; + } catch { + return false; + } +} + +async function applyFreshPostPluginDoctor(params: { + root: string; + pluginUpdate: PostCorePluginUpdateResult; + yes: boolean; + json: boolean; + timeoutMs: number; + nodeRunner?: string; +}): Promise<{ pluginUpdate: PostCorePluginUpdateResult; configValid: boolean }> { + let entryPath: string | undefined; + try { + entryPath = await resolveGatewayInstallEntrypoint(params.root); + } catch (err) { + return { + pluginUpdate: createPostPluginDoctorExecutionFailure(params.pluginUpdate, String(err)), + configValid: false, + }; + } + if (!entryPath) { + return { + pluginUpdate: createPostPluginDoctorExecutionFailure( + params.pluginUpdate, + "Updated OpenClaw entrypoint not found for post-plugin doctor", + ), + configValid: false, + }; + } + let pluginUpdate = params.pluginUpdate; + try { + await runPostPluginDoctorInFreshProcess({ ...params, entryPath }); + } catch (err) { + pluginUpdate = createPostPluginDoctorExecutionFailure(params.pluginUpdate, String(err)); + } + const configValid = await validatePostPluginConfigInFreshProcess({ ...params, entryPath }); + return { pluginUpdate, configValid }; +} + +export async function completePostCorePluginUpdate(params: { + root: string; + pluginUpdate: PostCorePluginUpdateResult; + freshDoctorRequired: boolean; + yes: boolean; + json: boolean; + timeoutMs: number; + nodeRunner?: string; +}): Promise<{ + pluginUpdate: PostCorePluginUpdateResult; + configSnapshot: ConfigFileSnapshot; +}> { + let pluginUpdate = params.pluginUpdate; + let freshConfigValid: boolean | undefined; + if (pluginUpdate.status !== "error" && params.freshDoctorRequired) { + // The current process can still hold the pre-update plugin and schema. Reload the updated + // migration owner before trusting strict validation or restarting the gateway. + const freshResult = await applyFreshPostPluginDoctor({ + root: params.root, + pluginUpdate, + yes: params.yes, + json: params.json, + timeoutMs: params.timeoutMs, + ...(params.nodeRunner ? { nodeRunner: params.nodeRunner } : {}), + }); + pluginUpdate = freshResult.pluginUpdate; + freshConfigValid = freshResult.configValid; + } + + const configSnapshot = await withNormalConfigValidation(() => readConfigFileSnapshot()); + // A plugin migration that did not converge must fail finalization instead of letting legacy + // config reach the restarted gateway. + // Two reads by design: the fresh child is the only process able to validate under the + // UPDATED schema, so its verdict gates the restart; this parent snapshot is best-effort + // state under the stale in-memory schema and the restarted gateway re-reads config anyway. + pluginUpdate = applyPostPluginConfigValidation( + pluginUpdate, + freshConfigValid ?? configSnapshot.valid, + ); + return { pluginUpdate, configSnapshot }; +} diff --git a/src/cli/update-cli/update-command-post-core.ts b/src/cli/update-cli/update-command-post-core.ts index 5e6b7001a6c7..a62a0c4d9dfe 100644 --- a/src/cli/update-cli/update-command-post-core.ts +++ b/src/cli/update-cli/update-command-post-core.ts @@ -7,10 +7,6 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { theme } from "../../../packages/terminal-core/src/theme.js"; import { doctorCommand } from "../../commands/doctor.js"; -import { - UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV, - UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV, -} from "../../commands/doctor/shared/update-phase.js"; import { assertConfigWriteAllowedInCurrentMode, readConfigFileSnapshot, @@ -70,6 +66,10 @@ import { restoreDroppedPreUpdateChannels, writePostCoreSourceConfigFile, } from "./update-command-config.js"; +import { + completePostCorePluginUpdate, + withUpdateFinalizationEnv, +} from "./update-command-fresh-doctor.js"; import { updatePluginsAfterCoreUpdate, type PostCorePluginUpdateResult, @@ -128,36 +128,6 @@ type UpdateFinalizeResult = { }; }; -function withUpdateFinalizationEnv(run: () => Promise): Promise { - const previousUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS; - const previousDeferConfiguredPluginInstallRepair = - process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]; - const previousParentSupportsDoctorConfigWrite = - process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]; - process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1"; - process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV] = "1"; - process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] = "1"; - return run().finally(() => { - if (previousUpdateInProgress === undefined) { - delete process.env.OPENCLAW_UPDATE_IN_PROGRESS; - } else { - process.env.OPENCLAW_UPDATE_IN_PROGRESS = previousUpdateInProgress; - } - if (previousDeferConfiguredPluginInstallRepair === undefined) { - delete process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]; - } else { - process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV] = - previousDeferConfiguredPluginInstallRepair; - } - if (previousParentSupportsDoctorConfigWrite === undefined) { - delete process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]; - } else { - process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] = - previousParentSupportsDoctorConfigWrite; - } - }); -} - export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promise { suppressDeprecations(); const timeoutMs = parseTimeoutMsOrExit(opts.timeout); @@ -225,7 +195,7 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis }); } - const pluginUpdate = await withUpdateFinalizationEnv(async () => { + const initialPluginUpdate = await withUpdateFinalizationEnv(async () => { await createUpdateConfigSnapshot(); await doctorCommand(defaultRuntime, { nonInteractive: true, @@ -268,6 +238,16 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis pluginInstallRecords, }); }); + const completedPluginUpdate = await completePostCorePluginUpdate({ + root, + pluginUpdate: initialPluginUpdate, + freshDoctorRequired: initialPluginUpdate.changed, + yes: opts.yes === true, + json: opts.json === true, + timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS, + }); + const pluginUpdate = completedPluginUpdate.pluginUpdate; + configSnapshot = completedPluginUpdate.configSnapshot; const result: UpdateFinalizeResult = { status: @@ -627,27 +607,28 @@ export async function continuePostCoreUpdateInFreshProcess(params: { } } +export function didCoreUpdateChangeInstall(result: UpdateRunResult): boolean { + if (isPackageManagerUpdateMode(result.mode)) { + return true; + } + if (result.mode !== "git") { + return false; + } + const beforeSha = normalizeOptionalString(result.before?.sha); + const afterSha = normalizeOptionalString(result.after?.sha); + if (beforeSha && afterSha && beforeSha !== afterSha) { + return true; + } + const beforeVersion = normalizeOptionalString(result.before?.version); + const afterVersion = normalizeOptionalString(result.after?.version); + return Boolean(beforeVersion && afterVersion && beforeVersion !== afterVersion); +} + export function shouldResumePostCoreUpdateInFreshProcess(params: { result: UpdateRunResult; downgradeRisk: boolean; }): boolean { - if (params.downgradeRisk) { - return false; - } - if (isPackageManagerUpdateMode(params.result.mode)) { - return true; - } - if (params.result.mode !== "git") { - return false; - } - const beforeSha = normalizeOptionalString(params.result.before?.sha); - const afterSha = normalizeOptionalString(params.result.after?.sha); - if (beforeSha && afterSha && beforeSha !== afterSha) { - return true; - } - const beforeVersion = normalizeOptionalString(params.result.before?.version); - const afterVersion = normalizeOptionalString(params.result.after?.version); - return Boolean(beforeVersion && afterVersion && beforeVersion !== afterVersion); + return !params.downgradeRisk && didCoreUpdateChangeInstall(params.result); } export async function writeControlPlaneUpdateRestartSentinelBestEffort(params: { diff --git a/src/cli/update-cli/update-command-post-plugin-validation.ts b/src/cli/update-cli/update-command-post-plugin-validation.ts new file mode 100644 index 000000000000..33eb306bf4bf --- /dev/null +++ b/src/cli/update-cli/update-command-post-plugin-validation.ts @@ -0,0 +1,30 @@ +import type { PostCorePluginUpdateResult } from "./update-command-plugins.js"; + +export const POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON = "post-plugin-doctor-execution-failed"; + +export function applyPostPluginConfigValidation( + pluginUpdate: PostCorePluginUpdateResult, + configValid: boolean, +): PostCorePluginUpdateResult { + if ( + configValid || + (pluginUpdate.status === "error" && + pluginUpdate.reason !== POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON) + ) { + return pluginUpdate; + } + return { + ...pluginUpdate, + status: "error", + reason: "post-plugin-doctor-invalid-config", + warnings: [ + ...(pluginUpdate.warnings ?? []), + { + reason: "Config remained invalid after updated plugin migrations.", + message: + "Post-update plugin migration did not produce a valid config; refusing to restart.", + guidance: ["Run `openclaw doctor --fix`, then rerun `openclaw update repair`."], + }, + ], + }; +} diff --git a/src/cli/update-cli/update-command-post-update.ts b/src/cli/update-cli/update-command-post-update.ts index 1413a18c99a9..436bf07e02e3 100644 --- a/src/cli/update-cli/update-command-post-update.ts +++ b/src/cli/update-cli/update-command-post-update.ts @@ -26,13 +26,16 @@ import { persistRequestedUpdateChannel, restoreDroppedPreUpdateChannels, } from "./update-command-config.js"; +import { completePostCorePluginUpdate } from "./update-command-fresh-doctor.js"; import { updatePluginsAfterCoreUpdate } from "./update-command-plugins.js"; import { continuePostCoreUpdateInFreshProcess, + didCoreUpdateChangeInstall, markControlPlaneUpdateRestartSentinelFailureBestEffort, shouldResumePostCoreUpdateInFreshProcess, writeControlPlaneUpdateRestartSentinelBestEffort, } from "./update-command-post-core.js"; +import { POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON } from "./update-command-post-plugin-validation.js"; import { gatewayServiceCommandUsesRoot, maybeRestartService, @@ -254,7 +257,7 @@ export async function finishUpdate(params: { process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = compatibilityDowngradeTarget; } try { - postCorePluginUpdate = await updatePluginsAfterCoreUpdate({ + const initialPluginUpdate = await updatePluginsAfterCoreUpdate({ root: postUpdateRoot, channel: params.channel, configSnapshot: postUpdateConfigSnapshot, @@ -264,6 +267,22 @@ export async function finishUpdate(params: { timeoutMs: params.updateStepTimeoutMs, pluginInstallRecords: params.preUpdatePluginInstallRecords, }); + const completedPluginUpdate = await completePostCorePluginUpdate({ + root: postUpdateRoot, + pluginUpdate: initialPluginUpdate, + // A plugin-only update can replace its migration owner without replacing core. + // Downgrades and resume fallbacks can also leave an updated core on disk in this process. + freshDoctorRequired: + didCoreUpdateChangeInstall(params.result) || + initialPluginUpdate.sync.changed || + initialPluginUpdate.npm.changed, + yes: params.opts.yes === true, + json: params.opts.json === true, + timeoutMs: params.updateStepTimeoutMs, + ...(params.packageUpdateNodeRunner ? { nodeRunner: params.packageUpdateNodeRunner } : {}), + }); + postCorePluginUpdate = completedPluginUpdate.pluginUpdate; + postUpdateConfigSnapshot = completedPluginUpdate.configSnapshot; } finally { if (compatibilityDowngradeTarget) { if (previousCompatibilityHostVersion === undefined) { @@ -296,6 +315,14 @@ export async function finishUpdate(params: { result: resultWithPostUpdate, jsonMode: Boolean(params.opts.json), }); + // If strict config became valid despite a fresh-doctor process failure, restore the service + // stopped by this update. Invalid post-migration config intentionally remains stopped. + if (postCorePluginUpdate.reason === POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON) { + await maybeRestartServiceAfterFailedMutableUpdate({ + preManagedServiceStop: params.preManagedServiceStop, + jsonMode: Boolean(params.opts.json), + }); + } if (params.opts.json) { defaultRuntime.writeJson(resultWithPostUpdate); } else { diff --git a/src/cli/update-cli/update-command-resume.ts b/src/cli/update-cli/update-command-resume.ts index 2d7f7ec60b7f..9b306158affd 100644 --- a/src/cli/update-cli/update-command-resume.ts +++ b/src/cli/update-cli/update-command-resume.ts @@ -11,6 +11,7 @@ import { readPostCorePreUpdateSourceConfig, restoreDroppedPreUpdateChannels, } from "./update-command-config.js"; +import { completePostCorePluginUpdate } from "./update-command-fresh-doctor.js"; import { updatePluginsAfterCoreUpdate } from "./update-command-plugins.js"; import { POST_CORE_UPDATE_INSTALL_RECORDS_PATH_ENV, @@ -75,7 +76,7 @@ export async function resumePostCoreUpdate(params: { ? currentPluginInstallRecords : parentPluginInstallRecords; - const pluginUpdate = await updatePluginsAfterCoreUpdate({ + const initialPluginUpdate = await updatePluginsAfterCoreUpdate({ root: params.root, channel: params.channel, configSnapshot: restoredConfig.snapshot, @@ -85,6 +86,15 @@ export async function resumePostCoreUpdate(params: { timeoutMs: params.timeoutMs, pluginInstallRecords, }); + const { pluginUpdate } = await completePostCorePluginUpdate({ + root: params.root, + pluginUpdate: initialPluginUpdate, + // Only package/channel sync can replace the migration owner loaded by this process. + freshDoctorRequired: initialPluginUpdate.sync.changed || initialPluginUpdate.npm.changed, + yes: params.opts.yes === true, + json: params.opts.json === true, + timeoutMs: params.timeoutMs, + }); if (process.env[POST_CORE_UPDATE_RESULT_PATH_ENV]) { await writePostCorePluginUpdateResultFile( process.env[POST_CORE_UPDATE_RESULT_PATH_ENV], diff --git a/src/cli/update-cli/update-command.test.ts b/src/cli/update-cli/update-command.test.ts index 55aca8ba8f25..57c598d8c963 100644 --- a/src/cli/update-cli/update-command.test.ts +++ b/src/cli/update-cli/update-command.test.ts @@ -6,13 +6,17 @@ import { describe, expect, it, vi } from "vitest"; import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js"; import type { GatewayService } from "../../daemon/service.js"; import type { UpdateRunResult } from "../../infra/update-runner.js"; -import { updatePluginsAfterCoreUpdate } from "./update-command-plugins.js"; +import { + updatePluginsAfterCoreUpdate, + type PostCorePluginUpdateResult, +} from "./update-command-plugins.js"; import { buildInvalidConfigPostCoreUpdateResult, collectMissingPluginInstallPayloads, resolvePostSyncPluginUpdateSkipIds, } from "./update-command-plugins.test-support.js"; import { resolvePostCoreUpdateChildStdio } from "./update-command-post-core.js"; +import { applyPostPluginConfigValidation } from "./update-command-post-plugin-validation.js"; import { resolvePostInstallDoctorEnv, resolvePostUpdateServiceStateReadEnv, @@ -51,6 +55,45 @@ describe("resolveGatewayInstallEntrypoint", () => { }); }); +describe("applyPostPluginConfigValidation", () => { + const pluginUpdate = { + status: "ok", + changed: true, + sync: { + changed: true, + switchedToBundled: [], + switchedToNpm: [], + warnings: [], + errors: [], + }, + npm: { changed: true, outcomes: [] }, + integrityDrifts: [], + warnings: [], + } satisfies PostCorePluginUpdateResult; + + it("fails closed when updated plugin migrations leave config invalid", () => { + expect(applyPostPluginConfigValidation(pluginUpdate, false)).toMatchObject({ + status: "error", + reason: "post-plugin-doctor-invalid-config", + warnings: [ + { + guidance: ["Run `openclaw doctor --fix`, then rerun `openclaw update repair`."], + }, + ], + }); + }); + + it("preserves an earlier plugin update error", () => { + const failed = { + ...pluginUpdate, + status: "error" as const, + reason: "plugin-sync-failed", + }; + + expect(applyPostPluginConfigValidation(failed, false)).toBe(failed); + }); +}); + describe("shouldPrepareUpdatedInstallRestart", () => { it("prepares package update restarts when the service is installed but stopped", () => { expect( diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index b2f8336688c6..16c02c64ac1f 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -2,6 +2,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { getBundledChannelSetupPlugin } from "../channels/plugins/bundled.js"; import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js"; +import { defineChannelSetupContract } from "../channels/plugins/setup-contract.js"; import type { ChannelSetupInput } from "../channels/plugins/types.core.js"; import type { ChannelPlugin } from "../channels/plugins/types.public.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -374,9 +375,6 @@ type SignalAfterAccountConfigWritten = NonNullable< type ApplyAccountConfigParams = Parameters< NonNullable["applyAccountConfig"]> >[0]; -type ResolveAccountIdParams = Parameters< - NonNullable["resolveAccountId"]> ->[0]; type PrepareAccountConfigInputParams = Parameters< NonNullable["prepareAccountConfigInput"]> >[0]; @@ -616,18 +614,30 @@ describe("channelsAddCommand", () => { }); it("maps legacy Nextcloud Talk add flags to setup input fields", async () => { - const applyAccountConfig = vi.fn(({ cfg, input }) => ({ - ...cfg, - channels: { - ...cfg.channels, - "nextcloud-talk": { - enabled: true, - baseUrl: input.baseUrl, - botSecret: input.secret, - botSecretFile: (input as NextcloudTalkSetupInput).secretFile, + const prepareAccountConfigInput = vi.fn(({ input }: PrepareAccountConfigInputParams) => { + const setupInput = input as NextcloudTalkSetupInput; + return { + ...setupInput, + baseUrl: setupInput.baseUrl ?? setupInput.url, + secret: setupInput.secret ?? setupInput.token ?? setupInput.password, + secretFile: setupInput.secretFile ?? setupInput.tokenFile, + }; + }); + const applyAccountConfig = vi.fn(({ cfg, input }: ApplyAccountConfigParams) => { + const setupInput = input as NextcloudTalkSetupInput; + return { + ...cfg, + channels: { + ...cfg.channels, + "nextcloud-talk": { + enabled: true, + baseUrl: setupInput.baseUrl, + botSecret: setupInput.secret, + botSecretFile: setupInput.secretFile, + }, }, - }, - })); + }; + }); setActivePluginRegistry( createTestRegistry([ { @@ -637,16 +647,7 @@ describe("channelsAddCommand", () => { id: "nextcloud-talk", label: "Nextcloud Talk", }), - setup: { - resolveAccountId: ({ accountId }: ResolveAccountIdParams) => accountId ?? "default", - prepareAccountConfigInput: ({ input }: PrepareAccountConfigInputParams) => ({ - ...input, - baseUrl: input.baseUrl ?? input.url, - secret: input.secret ?? input.token ?? input.password, - secretFile: (input as NextcloudTalkSetupInput).secretFile ?? input.tokenFile, - }), - applyAccountConfig, - }, + setup: { prepareAccountConfigInput, applyAccountConfig }, }, source: "test", }, @@ -756,6 +757,88 @@ describe("channelsAddCommand", () => { }); }); + it("uses channel-owned setup parsing for bundled plugins", async () => { + const applyAccountConfig = vi.fn(({ cfg, input }) => ({ + ...cfg, + channels: { + ...cfg.channels, + "typed-chat": { + token: input.token, + port: input.port, + }, + }, + })); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "typed-chat", + plugin: { + ...createChannelTestPluginBase({ id: "typed-chat", label: "Typed Chat" }), + setupContract: defineChannelSetupContract({ + fields: { + token: { + kind: "string", + cli: { flags: "--token ", description: "Bot token" }, + }, + port: { + kind: "integer", + cli: { flags: "--port ", description: "HTTP port" }, + }, + }, + adapter: { applyAccountConfig }, + }), + } as ChannelPlugin, + source: "test", + }, + ]), + ); + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); + + await channelsAddCommand({ channel: "typed-chat", token: "secret", port: "8080" }, runtime, { + hasFlags: true, + }); + + expect(writtenChannel("typed-chat")).toEqual({ token: "secret", port: 8080 }); + expect(applyAccountConfig).toHaveBeenCalledWith({ + cfg: baseConfigSnapshot.config, + accountId: "default", + input: { token: "secret", port: 8080 }, + }); + }); + + it("reports options that do not belong to the selected channel contract", async () => { + const applyAccountConfig = vi.fn(({ cfg }) => cfg); + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "typed-chat", + plugin: { + ...createChannelTestPluginBase({ id: "typed-chat", label: "Typed Chat" }), + setupContract: defineChannelSetupContract({ + fields: { + token: { + kind: "string", + cli: { flags: "--token ", description: "Bot token" }, + }, + }, + adapter: { applyAccountConfig }, + }), + } as ChannelPlugin, + source: "test", + }, + ]), + ); + configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); + + await channelsAddCommand({ channel: "typed-chat", signalTransport: "container" }, runtime, { + hasFlags: true, + }); + + expect(runtime.error).toHaveBeenCalledWith("Unsupported setup option: signalTransport"); + expect(runtime.exit).toHaveBeenCalledWith(1); + expect(applyAccountConfig).not.toHaveBeenCalled(); + }); + it("prepares setup input before validation, config writes, and post-write hooks", async () => { const callOrder: string[] = []; const beforePersistentEffect = vi.fn(async () => { @@ -1086,11 +1169,11 @@ describe("channelsAddCommand", () => { }, })); const plugin = { - ...createChannelTestPluginBase({ id: "matrix", label: "Matrix" }), + ...createChannelTestPluginBase({ id: "legacy-numeric", label: "Legacy Numeric" }), setup: { applyAccountConfig }, }; catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([ - createSetupOptionCatalogEntry("matrix", "Matrix", [ + createSetupOptionCatalogEntry("legacy-numeric", "Legacy Numeric", [ { flags: "--initial-sync-limit ", description: "Matrix initial sync limit", @@ -1099,12 +1182,14 @@ describe("channelsAddCommand", () => { ]), ]); configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); - setActivePluginRegistry(createTestRegistry([{ pluginId: "matrix", plugin, source: "test" }])); + setActivePluginRegistry( + createTestRegistry([{ pluginId: "legacy-numeric", plugin, source: "test" }]), + ); await expect( channelsAddCommand( { - channel: "matrix", + channel: "legacy-numeric", initialSyncLimit: "10x", }, runtime, @@ -1129,11 +1214,11 @@ describe("channelsAddCommand", () => { }, })); const plugin = { - ...createChannelTestPluginBase({ id: "tlon", label: "Tlon" }), + ...createChannelTestPluginBase({ id: "legacy-lists", label: "Legacy Lists" }), setup: { applyAccountConfig }, }; catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([ - createSetupOptionCatalogEntry("tlon", "Tlon", [ + createSetupOptionCatalogEntry("legacy-lists", "Legacy Lists", [ { flags: "--group-channels ", description: "Tlon group channels", @@ -1147,11 +1232,13 @@ describe("channelsAddCommand", () => { ]), ]); configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); - setActivePluginRegistry(createTestRegistry([{ pluginId: "tlon", plugin, source: "test" }])); + setActivePluginRegistry( + createTestRegistry([{ pluginId: "legacy-lists", plugin, source: "test" }]), + ); await channelsAddCommand( { - channel: "tlon", + channel: "legacy-lists", groupChannels: "chat/~host/general, chat/~host/random", dmAllowlist: "~zod;~nec", }, @@ -1178,10 +1265,10 @@ describe("channelsAddCommand", () => { }, })); catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([ - createSetupOptionCatalogEntry("matrix", "Matrix", [ - { flags: "--shared-value ", description: "Matrix shared value" }, + createSetupOptionCatalogEntry("legacy-scalar", "Legacy Scalar", [ + { flags: "--shared-value ", description: "Legacy scalar value" }, ]), - createSetupOptionCatalogEntry("tlon", "Tlon", [ + createSetupOptionCatalogEntry("legacy-list", "Legacy List", [ { flags: "--shared-value ", description: "Tlon shared values", @@ -1192,9 +1279,9 @@ describe("channelsAddCommand", () => { setActivePluginRegistry( createTestRegistry([ { - pluginId: "matrix", + pluginId: "legacy-scalar", plugin: { - ...createChannelTestPluginBase({ id: "matrix", label: "Matrix" }), + ...createChannelTestPluginBase({ id: "legacy-scalar", label: "Legacy Scalar" }), setup: { applyAccountConfig }, }, source: "test", @@ -1203,7 +1290,7 @@ describe("channelsAddCommand", () => { ); configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot }); - await channelsAddCommand({ channel: "matrix", sharedValue: "one,two" }, runtime, { + await channelsAddCommand({ channel: "legacy-scalar", sharedValue: "one,two" }, runtime, { hasFlags: true, }); diff --git a/src/commands/channels/add-mutators.ts b/src/commands/channels/add-mutators.ts index 5c6287143593..8da8423f901e 100644 --- a/src/commands/channels/add-mutators.ts +++ b/src/commands/channels/add-mutators.ts @@ -1,7 +1,8 @@ // Small channel config mutators used by guided and non-interactive channel add flows. import { getChannelPlugin } from "../../channels/plugins/index.js"; +import { resolveChannelSetupExecutionAdapter } from "../../channels/plugins/setup-contract.js"; import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; -import type { ChannelId, ChannelSetupInput } from "../../channels/plugins/types.public.js"; +import type { ChannelId } from "../../channels/plugins/types.public.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { normalizeAccountId } from "../../routing/session-key.js"; @@ -17,7 +18,7 @@ export function applyAccountName(params: { }): OpenClawConfig { const accountId = normalizeAccountId(params.accountId); const plugin = params.plugin ?? getChannelPlugin(params.channel); - const apply = plugin?.setup?.applyAccountName; + const apply = plugin ? resolveChannelSetupExecutionAdapter(plugin)?.applyAccountName : undefined; return apply ? apply({ cfg: params.cfg, accountId, name: params.name }) : params.cfg; } @@ -26,12 +27,14 @@ export function applyChannelAccountConfig(params: { cfg: OpenClawConfig; channel: ChatChannel; accountId: string; - input: ChannelSetupInput; + input: unknown; plugin?: ChannelPlugin; }): OpenClawConfig { const accountId = normalizeAccountId(params.accountId); const plugin = params.plugin ?? getChannelPlugin(params.channel); - const apply = plugin?.setup?.applyAccountConfig; + const apply = plugin + ? resolveChannelSetupExecutionAdapter(plugin)?.applyAccountConfig + : undefined; if (!apply) { return params.cfg; } diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index d04b22d61a6a..f0fd2cfb3184 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -5,6 +5,7 @@ import { getBundledChannelSetupPlugin } from "../../channels/plugins/bundled.js" import { resolveChannelSetupCliOptionMetadata } from "../../channels/plugins/cli-add-options.js"; import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js"; import { getLoadedChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; +import { resolveChannelSetupExecutionAdapter } from "../../channels/plugins/setup-contract.js"; import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js"; import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; import type { ChannelId, ChannelSetupInput } from "../../channels/plugins/types.public.js"; @@ -108,6 +109,17 @@ function buildChannelSetupInput(opts: ChannelsAddOptions): ChannelSetupInput { return input as ChannelSetupInput; } +// Safe to forward every defined key: CLI registration is selection-scoped and +// resolveChannelsAddOptions drops non-user-authored values (Commander defaults), +// so no other channel's options or defaults can reach the selected contract. +function buildChannelOwnedSetupInput(opts: ChannelsAddOptions): Record { + return Object.fromEntries( + Object.entries(opts).filter( + ([key, value]) => !CHANNEL_ADD_CONTROL_OPTION_KEYS.has(key) && value !== undefined, + ), + ); +} + /** Add or configure a channel account, using the wizard when no concrete flags are supplied. */ export async function channelsAddCommand( opts: ChannelsAddOptions, @@ -168,7 +180,7 @@ async function channelsAddCommandImpl( pluginId?: string, ): Promise => { const existing = getLoadedChannelPlugin(channelId); - if (existing?.setup?.applyAccountConfig) { + if (existing?.setupContract?.applyAccountConfig || existing?.setup?.applyAccountConfig) { return existing; } const { loadChannelSetupPluginRegistrySnapshotForChannel } = @@ -239,7 +251,8 @@ async function channelsAddCommandImpl( } const plugin = await loadScopedPlugin(channel, catalogEntry?.pluginId); - if (!plugin?.setup?.applyAccountConfig) { + const setup = plugin ? resolveChannelSetupExecutionAdapter(plugin) : undefined; + if (!plugin || !setup?.applyAccountConfig) { runtime.error( `${formatUnsupportedChannelActionMessage({ channel, @@ -249,16 +262,27 @@ async function channelsAddCommandImpl( runtime.exit(1); return; } - let input = buildChannelSetupInput(opts); + let input: unknown; + if (plugin.setupContract) { + const parsed = plugin.setupContract.parseInput(buildChannelOwnedSetupInput(opts)); + if (!parsed.ok) { + runtime.error(parsed.error); + runtime.exit(1); + return; + } + input = parsed.value; + } else { + input = buildChannelSetupInput(opts); + } const accountId = - plugin.setup.resolveAccountId?.({ + setup.resolveAccountId?.({ cfg: nextConfig, accountId: opts.account, input, }) ?? normalizeAccountId(opts.account); - if (plugin.setup.prepareAccountConfigInput) { + if (setup.prepareAccountConfigInput) { await params?.beforePersistentEffect?.(); - input = await plugin.setup.prepareAccountConfigInput({ + input = await setup.prepareAccountConfigInput({ cfg: nextConfig, accountId, input, @@ -266,7 +290,7 @@ async function channelsAddCommandImpl( }); } - const validationError = plugin.setup.validateInput?.({ + const validationError = setup.validateInput?.({ cfg: nextConfig, accountId, input, @@ -319,7 +343,7 @@ async function channelsAddCommandImpl( }); } runtime.log(`Added ${plugin.meta.label ?? channelLabel(channel)} account "${accountId}".`); - const afterAccountConfigWritten = plugin.setup?.afterAccountConfigWritten; + const afterAccountConfigWritten = setup.afterAccountConfigWritten; if (afterAccountConfigWritten) { const { runCollectedChannelOnboardingPostWriteHooks } = await loadOnboardChannels(); await runCollectedChannelOnboardingPostWriteHooks({ diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index c8118c6e4622..85b071772d40 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -25,14 +25,14 @@ const RAW_BUNDLED_CHANNEL_CONFIG_METADATA = [ '"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"account":{"type":"string"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false}},"rooms":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"account":{"type":"string"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"autoReply":{"type":"boolean"},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false}},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"profile":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"verification":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false},"uiHints":{"mentionPatterns":{"label":"Matrix Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Matrix room IDs. Native Matrix mention evidence still triggers even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Matrix Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Matrix Mention Pattern Allowlist","help":"Matrix room IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Matrix Mention Pattern Denylist","help":"Matrix room IDs where configured regex mention patterns are disabled. Native mention evidence still triggers."},"allowBots":{"label":"Matrix Allow Bot Messages","help":"Allow messages from other configured Matrix bot accounts to trigger replies (default: false). Set \\"mentions\\" to require a visible room mention."},"botLoopProtection":{"label":"Matrix Bot Loop Protection","help":"Sliding-window guard for accepted Matrix configured-bot loops. Default is enabled whenever allowBots lets configured bot messages reach dispatch."},"botLoopProtection.enabled":{"label":"Matrix Bot Loop Protection Enabled","help":"Enable the bot-pair loop guard. Defaults to true when allowBots is true or \\"mentions\\", and false when configured bot messages are ignored."},"botLoopProtection.maxEventsPerWindow":{"label":"Matrix Bot Loop Events per Window","help":"Maximum accepted bot-pair messages within the sliding window before suppression starts. Default: 20."},"botLoopProtection.windowSeconds":{"label":"Matrix Bot Loop Window Seconds","help":"Sliding window length for counting bot-pair messages. Default: 60."},"botLoopProtection.cooldownSeconds":{"label":"Matrix Bot Loop Cooldown Seconds","help":"How long to suppress the bot pair after it exceeds the budget. Default: 60."},"dangerouslyAllowNameMatching":{"label":"Matrix Display Name Matching","help":"Compatibility opt-in for resolving Matrix display names and joined room names in allowlists. Prefer full @user:server IDs and room IDs or aliases because names are mutable."},"streaming.progress.label":{"label":"Matrix Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Matrix Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use the built-in \\"Working\\" label."},"streaming.progress.maxLines":{"label":"Matrix Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Matrix Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Matrix Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Matrix Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."}}},{"pluginId":"mattermost","channelId":"mattermost","order":65,"channelEnvVars":["MATTERMOST_BOT_TOKEN","MATTERMOST_URL"],"label":"Mattermost","description":"self-hosted Slack-style chat; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"type":"string","enum":["off","first","all","batched"]},"group":{"type":"string","enum":["off","first","all","batched"]},"channel":{"type":"string","enum":["off","first","all","batched"]}},"additionalProperties":false},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"dangerouslyAllowNameMatching":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"enabled":{"type":"boolean"},"configWrites":{"type":"boolean"},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"baseUrl":{"type":"string"},"chatmode":{"type":"string","enum":["oncall","onmessage","onchar"]},"oncharPrefixes":{"type":"array","items":{"type":"string"}},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"replyToMode":{"type":"string","enum":["off","first","all","batched"]},"replyToModeByChatType":{"type":"object","properties":{"direct":{"type":"string","enum":["off","first","all","batched"]},"group":{"type":"string","enum":["off","first","all","batched"]},"channel":{"type":"string","enum":["off","first","all","batched"]}},"additionalProperties":false},"responsePrefix":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"callbackPath":{"type":"string"},"callbackUrl":{"type":"string"}},"additionalProperties":false},"interactions":{"type":"object","properties":{"callbackBaseUrl":{"type":"string"},"allowedSourceIps":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"dmChannelRetry":{"type":"object","properties":{"maxRetries":{"type":"integer","minimum":0,"maximum":10},"initialDelayMs":{"type":"integer","minimum":100,"maximum":60000},"maxDelayMs":{"type":"integer","minimum":1000,"maximum":60000},"timeoutMs":{"type":"integer","minimum":5000,"maximum":120000}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Mattermost","help":"Mattermost channel provider configuration for bot auth, access policy, slash commands, and preview streaming."},"dmPolicy":{"label":"Mattermost DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.mattermost.allowFrom=[\\"*\\"]."},"implicitMentions":{"label":"Mattermost Implicit Mentions","help":"Control which Mattermost reply, quote, and thread-participation signals count as mentions. Unset flags preserve the channel defaults."},"implicitMentions.replyToBot":{"label":"Mattermost Replies to Bot","help":"Treat replies to the bot\'s own messages as implicit mentions when the channel reports that signal."},"implicitMentions.quotedBot":{"label":"Mattermost Quoted Bot Messages","help":"Treat messages quoting the bot as impl', 'icit mentions when the channel reports that signal."},"implicitMentions.threadParticipation":{"label":"Mattermost Thread Participation","help":"Treat follow-ups in threads where the bot participated as implicit mentions when the channel reports that signal."},"streaming":{"label":"Mattermost Streaming Mode","help":"Unified Mattermost stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". \\"progress\\" keeps a single editable progress draft until final delivery."},"streaming.mode":{"label":"Mattermost Streaming Mode","help":"Canonical Mattermost preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.progress.label":{"label":"Mattermost Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Mattermost Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use the built-in \\"Working\\" label."},"streaming.progress.maxLines":{"label":"Mattermost Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Mattermost Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Mattermost Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Mattermost Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.preview.toolProgress":{"label":"Mattermost Draft Tool Progress","help":"Show tool/progress activity in the live draft preview post (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Mattermost Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.block.enabled":{"label":"Mattermost Block Streaming Enabled","help":"Enable chunked block-style Mattermost preview delivery when channels.mattermost.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Mattermost Block Streaming Coalesce","help":"Merge streamed Mattermost block replies before final delivery."}}},{"pluginId":"msteams","channelId":"msteams","aliases":["teams"],"order":60,"channelEnvVars":["MSTEAMS_APP_ID","MSTEAMS_APP_PASSWORD","MSTEAMS_TENANT_ID"],"label":"Microsoft Teams","description":"Teams SDK; enterprise support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"dangerouslyAllowNameMatching":{"type":"boolean"},"appId":{"type":"string"},"appPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tenantId":{"type":"string"},"cloud":{"type":"string","enum":["Public","USGov","USGovDoD","China"]},"serviceUrl":{"type":"string","format":"uri"},"authType":{"type":"string","enum":["secret","federated"]},"certificatePath":{"type":"string"},"certificateThumbprint":{"type":"string"},"useManagedIdentity":{"type":"boolean"},"managedIdentityClientId":{"type":"string"},"webhook":{"type":"object","properties":{"port":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"path":{"type":"string"}},"additionalProperties":false},"typingIndicator":{"type":"boolean"},"mediaAllowHosts":{"type":"array","items":{"type":"string"}},"mediaAuthAllowHosts":{"type":"array","items":{"type":"string"}},"graphMediaFallback":{"type":"boolean"},"requireMention":{"type":"boolean"},"replyStyle":{"type":"string","enum":["thread","top-level"]},"teams":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"replyStyle":{"type":"string","enum":["thread","top-level"]}},"additionalProperties":false}}},"additionalProperties":false}},"sharePointSiteId":{"type":"string"},"welcomeCard":{"type":"boolean"},"promptStarters":{"type":"array","items":{"type":"string"}},"groupWelcomeCard":{"type":"boolean"},"feedbackEnabled":{"type":"boolean"},"feedbackReflection":{"type":"boolean"},"feedbackReflectionCooldownMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"delegatedAuth":{"type":"object","properties":{"enabled":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"sso":{"type":"object","properties":{"enabled":{"type":"boolean"},"connectionName":{"type":"string"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"MS Teams","help":"Microsoft Teams channel provider configuration and provider-specific policy toggles. Use this section to isolate Teams behavior from other enterprise chat providers."},"configWrites":{"label":"MS Teams Config Writes","help":"Allow Microsoft Teams to write config in response to channel events/commands (default: true)."},"cloud":{"label":"MS Teams Cloud","help":"Teams SDK cloud environment for auth, token validation, and token services: \\"Public\\", \\"USGov\\", \\"USGovDoD\\", or \\"China\\" (default: Public)."},"serviceUrl":{"label":"MS Teams Service URL","help":"Bot Connector service URL for SDK proactive sends/edits/deletes. Set with cloud for USGov/DoD; set alone for GCC."},"graphMediaFallback":{"label":"MS Teams Graph Media Fallback","help":"Query Microsoft Graph for unresolved channel or group-chat HTML media. Adds one lookup per matching message when enabled (default: false)."},"streaming":{"label":"MS Teams Streaming","help":"Microsoft Teams preview/progress streaming mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Personal chats use Teams native streaminfo progress when available."},"streaming.progress.label":{"label":"MS Teams Progress Label","help":"Initial progress title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"MS Teams Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"MS Teams Progress Max Lines","help":"Maximum number of compact progress lines to keep below the progress title (default: 8)."},"streaming.progress.maxLineChars":{"label":"MS Teams Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"MS Teams Progress Tool Lines","help":"Show compact tool/progress lines in progress mode (default: true). Set false to keep only the title until final delivery."},"streaming.progress.commandText":{"label":"MS Teams Progress Command Text","help":"Command/exec detail in progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."}}},{"pluginId":"nextcloud-talk","channelId":"nextcloud-talk","aliases":["nc","nc-talk"],"order":65,"channelEnvVars":["NEXTCLOUD_TALK_API_PASSWORD","NEXTCLOUD_TALK_BOT_SECRET"],"label":"Nextcloud Talk","description":"Self-hosted chat via Nextcloud Talk webhook bots.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"', 'type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"baseUrl":{"type":"string"},"botSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"botSecretFile":{"type":"string"},"apiUser":{"type":"string"},"apiPassword":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"apiPasswordFile":{"type":"string"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"webhookPort":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"webhookHost":{"type":"string"},"webhookPath":{"type":"string"},"webhookPublicUrl":{"type":"string"},"allowFrom":{"type":"array","items":{"type":"string"}},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"rooms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},{"pluginId":"nostr","channelId":"nostr","order":55,"channelEnvVars":["NOSTR_PRIVATE_KEY"],"label":"Nostr","description":"Decentralized protocol; encrypted DMs via NIP-04.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"defaultAccount":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"privateKey":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"relays":{"type":"array","items":{"type":"string"}},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"profile":{"type":"object","properties":{"name":{"type":"string","maxLength":256},"displayName":{"type":"string","maxLength":256},"about":{"type":"string","maxLength":2000},"picture":{"type":"string","format":"uri"},"banner":{"type":"string","format":"uri"},"website":{"type":"string","format":"uri"},"nip05":{"type":"string"},"lud16":{"type":"string"}},"additionalProperties":false}},"additionalProperties":false}},{"pluginId":"qa-channel","channelId":"qa-channel","order":999,"configurable":false,"label":"QA Channel","description":"Synthetic Slack-class transport for automated OpenClaw QA scenarios.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"baseUrl":{"type":"string","format":"uri"},"botUserId":{"type":"string"},"botDisplayName":{"type":"string"},"pollTimeoutMs":{"type":"integer","minimum":100,"maximum":30000},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}}},"additionalProperties":false}},"defaultTo":{"type":"string"},"actions":{"type":"object","properties":{"messages":{"type":"boolean"},"reactions":{"type":"boolean"},"search":{"type":"boolean"},"threads":{"type":"boolean"}},"additionalProperties":false}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"qqbot","channelId":"qqbot","channelEnvVars":["QQBOT_APP_ID","QQBOT_CLIENT_SECRET"],"label":"QQ Bot","description":"connect to QQ via official QQ Bot API with group chat and direct message support.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"voiceDirectUploadFormats":{"type":"array","items":{"type":"string"}},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"nativeTransport":{"type":"boolean"}},"required":["mode"],"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"commandLevel":{"type":"string","enum":["all","safety","strict"]},"ignoreOtherMentions":{"type":"boolean"},"historyLimit":{"type":"number"},"name":{"type":"string"},"prompt":{"type":"string"}},"additionalProperties":false}},"stt":{"type":"object","properties":{"enabled":{"type":"boolean"},"provider":{"type":"string"},"baseUrl":{"type":"string"},"apiKey":{"type":"string"},"model":{"type":"string"}},"additionalProperties":false},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"appId":{"type":"string"},"clientSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"clientSecretFile":{"type":"string"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"dmPolicy":{"type":"string","enum":["open","allowlist","disabled"]},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"systemPrompt":{"type":"string"},"markdownSupport":{"type":"boolean"},"voiceDirectUploadFormats":{"type":"array","items":{"type":"string"}},"audioFormatPolicy":{"type":"object","properties":{"sttDirectFormats":{"type":"array","items":{"type":"string"}},"uploadDirectFormats":{"type":"array","items":{"type":"string"}},"transcodeEnabled":{"type":"boolean"}},"additionalProperties":false},"urlDirectUpload":{"type":"boolean"},"upgradeUrl":{"type":"string"},"upgradeMode":{"type":"string","enum":["doc","hot-reload"]},"streaming":{"type":"object","properties":{"mode":{"default":"partial","type":"string","enum":["off","partial"]},"nativeTransport":{"type":"boolean"}},"required":["mode"],"additionalProperties":false},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"type":"string"}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"t', - 'ype":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"commandLevel":{"type":"string","enum":["all","safety","strict"]},"ignoreOtherMentions":{"type":"boolean"},"historyLimit":{"type":"number"},"name":{"type":"string"},"prompt":{"type":"string"}},"additionalProperties":false}}},"additionalProperties":{}}},"defaultAccount":{"type":"string"}},"additionalProperties":{}}},{"pluginId":"raft","channelId":"raft","order":72,"channelEnvVars":["RAFT_PROFILE"],"label":"Raft","description":"Raft CLI wake bridge for human and agent collaboration.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"profile":{"type":"string","minLength":1},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"profile":{"type":"string","minLength":1}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"reef","channelId":"reef","label":"Reef","description":"Guarded end-to-end encrypted claw messaging.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"default":true,"type":"boolean"},"relayUrl":{"default":"https://reefwire.ai","type":"string","format":"uri","pattern":"^[hH][tT][tT][pP][sS]?:\\\\/\\\\/[^\\\\\\\\/?#@]+\\\\/?$"},"handle":{"type":"string","pattern":"^[a-z0-9][a-z0-9_-]{0,62}$"},"email":{"type":"string","format":"email","pattern":"^(?!\\\\.)(?!.*\\\\.\\\\.)([A-Za-z0-9_\'+\\\\-\\\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\\\-]*\\\\.)+[A-Za-z]{2,}$"},"guard":{"type":"object","properties":{"provider":{"type":"string","enum":["anthropic","openai"]},"pinnedModel":{"type":"string","minLength":1},"apiKeyEnv":{"type":"string","pattern":"^[A-Z_][A-Z0-9_]*$"},"policyVersion":{"type":"string","minLength":1},"timeoutMs":{"type":"integer","minimum":100,"maximum":120000}},"required":["provider","pinnedModel","apiKeyEnv","policyVersion","timeoutMs"],"additionalProperties":false},"stateDir":{"type":"string","minLength":1},"requestPolicy":{"default":"code-only","type":"string","enum":["code-only","friends-of-friends","open"]},"friends":{}},"required":["enabled","relayUrl","requestPolicy"],"additionalProperties":false}},{"pluginId":"signal","channelId":"signal","label":"Signal","description":"signal-cli linked device; more setup (David Reagans: \\"Hop on Discord.\\").","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"account":{"type":"string"},"accountUuid":{"type":"string"},"configPath":{"type":"string"},"httpUrl":{"type":"string"},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"aliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"ingest":{"type":"boolean"}},"additionalProperties":false}},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"apiMode":{"type":"string","enum":["auto","native","container"]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"account":{"type":"string"},"accountUuid":{"type":"string"},"configPath":{"type":"string"},"httpUrl":{"type":"string"},"cliPath":{"type":"string"},"autoStart":{"type":"boolean"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreAttachments":{"type":"boolean"},"ignoreStories":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"aliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"ingest":{"type":"boolean"}},"additionalProperties":false}},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Signal","help":"Signal channel provider configuration including account identity and DM policy behavior. Keep account mapping explicit so routing remains stable across multi-device setups."},"dmPolicy":{"label":"Signal DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.signal.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Signal Config Writes","help":"Allow Signal to write config in response to channel events/commands (default: true)."},"account":{"label":"Signal Account","help":"Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state.","presentation":"phone-number"},"allowFrom":{"presentation":"phone-number"},"defaultTo":{"presentation":"phone-number"},"groupAllowFrom":{"presentation":"phone-number"},"reactionAllowlist":{"presentation":"phone-number"},"accounts.*.account":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.groupAllowFrom.*":{"presentation":"phone-number"},"accounts.*.reactionAllowlist.*":{"presentation":"phone-number"},"configPath":{"label":"Signal CLI Config Path","help":"Optional directory passed to signal-cli via --config when the service needs a non-default signal-cli data path."}}},{"pluginId":"slack","channelId":"slack","channelEnvVars":["SLACK_APP_TOKEN","SLACK_BOT_TOKEN","SLACK_USER_TOKEN"],"label":"Slack","description":"supported (Socket Mode).","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"inte', - 'ger","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"},"nativeTaskCards":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"postAs":{"default":"bot","type":"string","enum":["bot","user"]},"mode":{"default":"socket","type":"string","enum":["socket","http","relay"]},"enterpriseOrgInstall":{"type":"boolean"},"relay":{"type":"object","properties":{"url":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"gatewayId":{"type":"string"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"default":"/slack/events","type":"string"},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"unfurlLinks":{"type":"boolean"},"unfurlMedia":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"ackReaction":{"type":"string"},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false}},"additionalProperties":false}},"typingReaction":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"},"nativeTaskCards":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"postAs":{"type":"string","enum":["bot","user"]},"mode":{"type":"string","enum":["socket","http","relay"]},"enterpriseOrgInstall":{"type":"boolean"},"relay":{"type":"object","properties":{"url":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"gatewayId":{"type":"string"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["sou', - 'rce","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"unfurlLinks":{"type":"boolean"},"unfurlMedia":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"ackReaction":{"type":"string"},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false}},"additionalProperties":false}},"typingReaction":{"type":"string"}},"required":["userTokenReadOnly"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy","postAs","mode","webhookPath","userTokenReadOnly"],"additionalProperties":false},"uiHints":{"":{"label":"Slack","help":"Slack channel provider configuration for bot/app tokens, streaming behavior, and DM policy controls. Keep token handling and thread behavior explicit to avoid noisy workspace interactions."},"enterpriseOrgInstall":{"label":"Slack Enterprise Grid Org Install","help":"Enable only for an Enterprise Grid org-wide bot installation. OpenClaw verifies the token with Slack auth.test at startup; DMs must be disabled or use dmPolicy=\\"open\\" with allowFrom=[\\"*\\"]."},"postAs":{"label":"Slack Identity","help":"Select \\"bot\\" (default) for the classic Slack app/bot identity or \\"user\\" to post as the authorizing human through a user token while the app carries event transport."},"dmPolicy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Slack Config Writes","help":"Allow Slack to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"Slack Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Slack channel IDs. Native Slack @mentions still trigger even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Slack Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Slack Mention Pattern Allowlist","help":"Slack channel IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Slack Mention Pattern Denylist","help":"Slack channel IDs where configured regex mention patterns are disabled. Native @mentions still trigger."},"commands.native":{"label":"Slack Native Commands","help":"Override native commands for Slack (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Slack Native Skill Commands","help":"Override native skill commands for Slack (bool or \\"auto\\")."},"implicitMentions":{"label":"Slack Implicit Mentions","help":"Control which Slack reply, quote, and thread-participation signals count as mentions. Unset flags preserve the channel defaults."},"implicitMentions.replyToBot":{"label":"Slack Replies to Bot","help":"Treat replies to the bot\'s own messages as implicit mentions when the channel reports that signal."},"implicitMentions.quotedBot":{"label":"Slack Quoted Bot Messages","help":"Treat messages quoting the bot as implicit mentions when the channel reports that signal."},"implicitMentions.threadParticipation":{"label":"Slack Thread Participation","help":"Treat follow-ups in threads where the bot participated as implicit mentions when the channel reports that signal."},"allowBots":{"label":"Slack Allow Bot Messages","help":"Allow bot-authored messages to trigger Slack replies (default: false)."},"botLoopProtection":{"label":"Slack Bot Loop Protection","help":"Sliding-window guard for Slack bot-to-bot loops. Default is enabled whenever allowBots lets bot-authored messages reach dispatch."},"botLoopProtection.enabled":{"label":"Slack Bot Loop Protection Enabled","help":"Enable the bot-pair loop guard. Defaults to true when allowBots is true or \\"mentions\\", and false when bot messages are ignored."},"botLoopProtection.maxEventsPerWindow":{"label":"Slack Bot Loop Events per Window","help":"Maximum accepted bot-pair messages within the sliding window before suppression starts. Default: 20."},"botLoopProtection.windowSeconds":{"label":"Slack Bot Loop Window Seconds","help":"Sliding window length for counting bot-pair messages. Default: 60."},"botLoopProtection.cooldownSeconds":{"label":"Slack Bot Loop Cooldown Seconds","help":"How long to suppress the bot pair after it exceeds the budget. Default: 60."},"relay":{"label":"Slack Relay Mode","help":"Relay-delivered Slack events. Use with mode=\\"relay\\" when openclaw-slack-router owns the Slack Socket Mode connection."},"relay.url":{"label":"Slack Relay URL","help":"Full websocket URL for openclaw-slack-router. Include the route path, for example ws://127.0.0.1:8081/gateway/ws."},"relay.authToken":{"label":"Slack Relay Auth Token","help":"Bearer token used by this gateway to authenticate its reverse websocket connection to openclaw-slack-router."},"relay.gatewayId":{"label":"Slack Relay Gateway ID","help":"Destination id that openclaw-slack-router uses when routing user-group mentions to this gateway."},"botToken":{"label":"Slack Bot Token","help":"Slack bot token used for standard chat actions in the configured workspace. Keep this credential scoped and rotate if workspace app permissions change."},"appToken":{"label":"Slack App Token","help":"Slack app-level token used for Socket Mode connections and event transport when enabled. Use least-privilege app scopes and store this token as a secret."},"userToken":{"label":"Slack User Token","help":"Optional Slack user token for workflows requiring user-context API access beyond bot permissions. Use sparingly and audit scopes because this token can carry broader authority."},"userTokenReadOnly":{"label":"Slack User Token Read Only","help":"When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes."},"capabilities.interactiveReplies":{"label":"Slack Interactive Replies","help":"Enable agent-authored Slack interactive reply directives (`[[slack_buttons: ...]]`, `[[slack_select: ...]]`). Default: false."},"execApprovals":{"label":"Slack Exec Approvals","help":"Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account."},"presenceEvents":{"label":"Slack Presence Events","help":"Poll observed human participants and wake the routed agent on away-to-active transitions. Default: \\"off\\"."},"presenceEvents.mode":{"label":"Slack Presence Event Mode","help":"\\"off\\" disables polling; \\"auto\\" covers DMs, MPIMs, and recent threads with up to 8 observed people; \\"on\\" also covers larger threads and top-level channels."},"channels.*.presenceEvents.mode":{"label":"Slack Channel Presence Event Mode","help":"Override presence events for one Slack channel. Use \\"on\\" to include large threads or top-level channel sessions."},"execApprovals.enabled":{"label":"Slack Exec Approvals Enabled","help":"Controls Slack native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Slack Exec Approval Approvers","help":"Slack user IDs allowed to approve exec requests for this workspace account. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Slack Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Slack exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Slack."},"execApprovals.sessionFilter":{"label":"Slack Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Slack approval routing is used. Use narrow patterns so Slack approvals only appear for intended sessions."},"execApprovals.target":{"label":"Slack Exec Approval Target","help":"Controls where Slack approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Slack chat/thread, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted channels."},"streaming":{"label":"Slack Streaming Mode","help":"Unified Slack stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Slack Streaming Mode","help":"Canonical Slack preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Slack Chunk Mode","help":"Chunking mode for outbound Slack text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Slack Block Streaming Enabled","help":"Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Slack Block Streaming Coalesce","help":"Merge streamed Slack block replies before final delivery."},"streaming.nativeTransport":{"label":"Slack Native S', - 'treaming","help":"Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true). Native streaming and Slack assistant thread status require a reply thread target; top-level DMs can still use draft post-and-edit preview streaming."},"streaming.preview.toolProgress":{"label":"Slack Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Slack Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Slack Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Slack Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Slack Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Slack Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.render":{"label":"Slack Progress Renderer","help":"Progress draft renderer: \\"text\\" uses one portable text body; \\"rich\\" renders structured Slack Block Kit fields with the same text fallback."},"streaming.progress.nativeTaskCards":{"label":"Slack Native Progress Task Cards","help":"Opt in to Slack native task-card progress updates when channels.slack.streaming.mode=\\"progress\\" and streaming.nativeTransport is enabled. Default: false."},"streaming.progress.toolProgress":{"label":"Slack Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Slack Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"thread.historyScope":{"label":"Slack Thread History Scope","help":"Scope for Slack thread history context (\\"thread\\" isolates per thread; \\"channel\\" reuses channel history)."},"thread.inheritParent":{"label":"Slack Thread Parent Inheritance","help":"If true, Slack thread sessions inherit the parent channel transcript (default: false)."},"thread.initialHistoryLimit":{"label":"Slack Thread Initial History Limit","help":"Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable)."}}},{"pluginId":"sms","channelId":"sms","order":88,"channelEnvVars":["SMS_ALLOWED_USERS","SMS_PUBLIC_WEBHOOK_URL","SMS_WEBHOOK_PATH","TWILIO_ACCOUNT_SID","TWILIO_AUTH_TOKEN","TWILIO_MESSAGING_SERVICE_SID","TWILIO_PHONE_NUMBER","TWILIO_SMS_FROM"],"label":"SMS","description":"Twilio-backed SMS with inbound webhooks and outbound replies.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"accountSid":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"fromNumber":{"type":"string"},"messagingServiceSid":{"type":"string"},"defaultTo":{"type":"string"},"webhookPath":{"type":"string"},"publicWebhookUrl":{"type":"string"},"dangerouslyDisableSignatureValidation":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"accountSid":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"fromNumber":{"type":"string"},"messagingServiceSid":{"type":"string"},"defaultTo":{"type":"string"},"webhookPath":{"type":"string"},"publicWebhookUrl":{"type":"string"},"dangerouslyDisableSignatureValidation":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"required":["dmPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"SMS","help":"Twilio SMS channel configuration for inbound webhooks and outbound text replies."},"accountSid":{"label":"Twilio Account SID","help":"Twilio Account SID used for SMS outbound API calls."},"authToken":{"label":"Twilio Auth Token","help":"Twilio Auth Token used to sign webhook validation and SMS outbound API calls."},"fromNumber":{"label":"SMS From Number","help":"Twilio SMS-capable phone number in E.164 format, for example +15551234567.","presentation":"phone-number"},"messagingServiceSid":{"label":"Twilio Messaging Service SID","help":"Twilio Messaging Service SID to use instead of a dedicated fromNumber."},"defaultTo":{"label":"SMS Default To Number","help":"Optional default outbound phone number used when a send flow omits an explicit SMS target.","presentation":"phone-number"},"publicWebhookUrl":{"label":"SMS Public Webhook URL","help":"Public URL configured in Twilio for incoming messages. Must match Twilio\'s signed URL exactly."},"webhookPath":{"label":"SMS Webhook Path","help":"Gateway HTTP path that receives Twilio incoming-message webhooks. Use a distinct path per account."},"dmPolicy":{"label":"SMS DM Policy","help":"Direct SMS access control (\\"pairing\\" recommended). \\"open\\" requires channels.sms.allowFrom=[\\"*\\"]."},"allowFrom":{"label":"SMS Allow From","help":"Allowed sender phone numbers in E.164 format, or * when dmPolicy is open.","presentation":"phone-number"},"accounts.*.fromNumber":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"textChunkLimit":{"label":"SMS Text Chunk Limit","help":"Maximum characters per outbound SMS chunk before OpenClaw splits long replies."}}},{"pluginId":"synology-chat","channelId":"synology-chat","order":90,"channelEnvVars":["OPENCLAW_BOT_NAME","SYNOLOGY_ALLOWED_USER_IDS","SYNOLOGY_CHAT_INCOMING_URL","SYNOLOGY_CHAT_TOKEN","SYNOLOGY_NAS_HOST","SYNOLOGY_RATE_LIMIT"],"label":"Synology Chat","description":"Connect your Synology NAS Chat to OpenClaw with full agent capabilities.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"dangerouslyAllowNameMatching":{"type":"boolean"},"dangerouslyAllowInheritedWebhookPath":{"type":"boolean"}},"additionalProperties":{}}},{"pluginId":"telegram","channelId":"telegram","channelEnvVars":["TELEGRAM_BOT_TOKEN"],"label":"Telegram","description":"simplest way to get started — register a bot with @BotFather and get going.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"str', - 'ing","enum":["pairing","allowlist","open","disabled"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"richMessages":{"type":"boolean"},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"ackReaction":{"type":"string"},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"richMessages":{"type":"boolean"},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","prov', - 'ider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"ackReaction":{"type":"string"},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Telegram","help":"Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics."},"customCommands":{"label":"Telegram Custom Commands","help":"Additional Telegram bot menu commands (merged with native; conflicts ignored)."},"botToken":{"label":"Telegram Bot Token","help":"Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected."},"dmPolicy":{"label":"Telegram DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.telegram.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Telegram Config Writes","help":"Allow Telegram to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"Telegram Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Telegram group chat IDs or chatId:topic:threadId topic IDs. Native Telegram bot mentions still trigger even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Telegram Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Telegram Mention Pattern Allowlist","help":"Telegram group chat IDs or chatId:topic:threadId topic IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Telegram Mention Pattern Denylist","help":"Telegram group chat IDs or chatId:topic:threadId topic IDs where configured regex mention patterns are disabled. Native bot mentions still trigger."},"commands.native":{"label":"Telegram Native Commands","help":"Override native commands for Telegram (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Telegram Native Skill Commands","help":"Override native skill commands for Telegram (bool or \\"auto\\")."},"streaming":{"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\"). \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are detected; run doctor --fix to migrate."},"streaming.mode":{"label":"Telegram Streaming Mode","help":"Canonical Telegram preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\")."},"streaming.chunkMode":{"label":"Telegram Chunk Mode","help":"Chunking mode for outbound Telegram text delivery: \\"length\\" (default) or \\"newline\\"."},"richMessages":{"label":"Telegram Rich Messages","help":"Opt into Bot API 10.1 rich text sends and edits, including native tables and rich media. Default: false because some current Telegram clients render these messages as unsupported."},"streaming.block.enabled":{"label":"Telegram Block Streaming Enabled","help":"Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Telegram Block Streaming Coalesce","help":"Merge streamed Telegram block replies before sending final delivery."},"streaming.preview.chunk.minChars":{"label":"Telegram Draft Chunk Min Chars","help":"Minimum chars before emitting a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.maxChars":{"label":"Telegram Draft Chunk Max Chars","help":"Target max size for a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.breakPreference":{"label":"Telegram Draft Chunk Break Preference","help":"Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence)."},"streaming.preview.toolProgress":{"label":"Telegram Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true when preview streaming is active). Set false to keep tool updates out of the edited Telegram preview."},"streaming.preview.commandText":{"label":"Telegram Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Telegram Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Telegram Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use the built-in \\"Working\\" label."},"streaming.progress.maxLines":{"label":"Telegram Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Telegram Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Telegram Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Telegram Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.commentary":{"label":"Telegram Progress Commentary","help":"Show assistant commentary/preamble text in the temporary progress draft. Final answer delivery is unchanged."},"network.autoSelectFamily":{"label":"Telegram autoSelectFamily","help":"Override Node autoSelectFamily for Telegram (true=enable, false=disable)."},"network.dangerouslyAllowPrivateNetwork":{"label":"Telegram Dangerously Allow Private Network","help":"Dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve api.telegram.org to private/internal/special-use addresses."},"silentErrorReplies":{"label":"Telegram Silent Error Replies","help":"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false."},"apiRoot":{"label":"Telegram API Root URL","help":"Custom Telegram Bot API root URL. Use the API root only (for example https://api.telegram.org), not a full /bot endpoint. Use for self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) or reverse proxies in regions where api.telegram.org is blocked."},"trustedLocalFileRoots":{"label":"Telegram Trusted Local File Roots","help":"Trusted local filesystem roots for self-hosted Telegram Bot API file_path values. Exact in-root paths are read directly; container paths under /var/lib/telegram-bot-api can map into a host volume mount. Other absolute paths are rejected."},"autoTopicLabel":{"label":"Telegram Auto Topic Label","help":"Auto-rename DM forum topics on first message using LLM. Default: true. Set to false to disable, or use object form { enabled: true, prompt: \'...\' } for custom prompt."},"autoTopicLabel.enabled":{"label":"Telegram Auto Topic Label Enabled","help":"Whether auto topic labeling is enabled. Default: true."},"autoTopicLabel.prompt":{"label":"Telegram Auto Topic Label Prompt","help":"Custom prompt for LLM-based topic naming. The user message is appended after the prompt."},"capabilities.inlineButtons":{"label":"Telegram Inline Buttons","help":"Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior."},"execApprovals":{"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account."},"execApprovals.enabled":{"label":"Telegram Exec Approvals Enabled","help":"Controls Telegram native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Telegram Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram."},"execApprovals.sessionFilter":{"label":"Telegram Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions."},"execApprovals.target":{"label":"Telegram Exec Approval Target","help":"Controls where Telegram approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Telegram chat/topic, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics."},"threadBindings.enabled":{"label":"Telegram Thread Binding Enabled","help":"Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Telegram Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Telegram bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Telegram Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Telegram Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported."},"threadBindings.defaultSpawnContext":{"label":"Telegram Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."}}},{"pluginId":"tlon","channelId":"tlon","order":90,"label":"Tlon","description":"decentralized messaging on Urbit; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1},"authorization":{"type":"object","properties":{"channelRules":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"mode":{"type":"string","enum":["restricted","open"]},"allowedShips":{"type":"array","items":{"type":"string","minLength":1}}},"additionalProperties":false}}},"additionalProperties":false},"defaultAuthorizedShips":{"type":"array","items":{"type":"string","minLength":1}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1}},"additionalProperties":false}}},"additionalProperties":false},"uiHints":{"implicitMentions":{"label":"Tlon Implicit Mentions","help":"Control which Tlon reply, quote, and thread-participation signals count as mentions. Unset flags preserve the channel defaults."},"implicitMentions.replyToBot":{"label":"Tlon Replies to Bot","help":"Treat replies to the bot\'s own messages as implicit mentions when the channel reports that signal."},"implicitMentions.quotedBot":{"label":"Tlon Quoted Bot Messages","help":"Treat messages quoting the bot as implicit mentions when the channel reports that signal."},"implicitMentions.threadParticipation":{"label":"Tlon Thread Participation","help":"Treat follow-ups in threads where the bot participated as implicit mentions when the chann', - 'el reports that signal."}}},{"pluginId":"twitch","channelId":"twitch","aliases":["twitch-chat"],"channelEnvVars":["OPENCLAW_TWITCH_ACCESS_TOKEN"],"label":"Twitch","description":"Twitch chat integration","schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false},{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false}}},"required":["accounts"],"additionalProperties":false}]}},{"pluginId":"whatsapp","channelId":"whatsapp","label":"WhatsApp","description":"works with your own number; recommend a separate phone + eSIM.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"default":50,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"sendReadReceipts":{"type":"boolean"},"selfChatMode":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"default":0,"type":"integer","minimum":0,"maximum":9007199254740991},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"sendReadReceipts":{"type":"boolean"},"selfChatMode":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"name":{"type":"string"},"authDir":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"polls":{"type":"boolean"},"calls":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy","mediaMaxMb","debounceMs"],"additionalProperties":false},"uiHints":{"":{"label":"WhatsApp","help":"WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats."},"dmPolicy":{"label":"WhatsApp DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.whatsapp.allowFrom=[\\"*\\"]."},"allowFrom":{"presentation":"phone-number"},"defaultTo":{"presentation":"phone-number"},"groupAllowFrom":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.groupAllowFrom.*":{"presentation":"phone-number"},"selfChatMode":{"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number)."},"debounceMs":{"label":"WhatsApp Message Debounce (ms)","help":"Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable)."},"configWrites":{"label":"WhatsApp Config Writes","help":"Allow WhatsApp to write config in response to channel events/commands (default: true)."},"actions.calls":{"label":"WhatsApp Voice Calls","help":"Expose the experimental requester-bound WhatsApp voice-call tool. Default: false. Requires a separately paired MeowCaller CLI."},"mentionPatterns":{"label":"WhatsApp Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected WhatsApp conversation IDs such as 123@g.us."},"mentionPatterns.mode":{"label":"WhatsApp Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"WhatsApp Mention Pattern Allowlist","help":"WhatsApp conversation IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"WhatsApp Mention Pattern Denylist","help":"WhatsApp conversation IDs where configured regex mention patterns are disabled."}},"unsupportedSecretRefSurfacePatterns":["channels.whatsapp.accounts.*.creds.json","channels.whatsapp.creds.json"]},{"pluginId":"zalo","channelId":"zalo","aliases":["zl"],"order":80,"channelEnvVars":["ZALO_BOT_TOKEN","ZALO_WEBHOOK_SECRET"],"label":"Zalo","description":"Vietnam-focused messaging platform with Bot API.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"t', - 'ype":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"zalouser","channelId":"zalouser","aliases":["zlu"],"order":85,"channelEnvVars":["ZALOUSER_PROFILE","ZCA_PROFILE"],"label":"Zalo Personal","description":"Zalo personal account via QR code login.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"enabled":{"type":"boolean"}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"enabled":{"type":"boolean"}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}}]', + 'ype":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"commandLevel":{"type":"string","enum":["all","safety","strict"]},"ignoreOtherMentions":{"type":"boolean"},"historyLimit":{"type":"number"},"name":{"type":"string"},"prompt":{"type":"string"}},"additionalProperties":false}}},"additionalProperties":{}}},"defaultAccount":{"type":"string"}},"additionalProperties":{}}},{"pluginId":"raft","channelId":"raft","order":72,"channelEnvVars":["RAFT_PROFILE"],"label":"Raft","description":"Raft CLI wake bridge for human and agent collaboration.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"profile":{"type":"string","minLength":1},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"profile":{"type":"string","minLength":1}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"reef","channelId":"reef","label":"Reef","description":"Guarded end-to-end encrypted claw messaging.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"enabled":{"default":true,"type":"boolean"},"relayUrl":{"default":"https://reefwire.ai","type":"string","format":"uri","pattern":"^[hH][tT][tT][pP][sS]?:\\\\/\\\\/[^\\\\\\\\/?#@]+\\\\/?$"},"handle":{"type":"string","pattern":"^[a-z0-9][a-z0-9_-]{0,62}$"},"email":{"type":"string","format":"email","pattern":"^(?!\\\\.)(?!.*\\\\.\\\\.)([A-Za-z0-9_\'+\\\\-\\\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\\\-]*\\\\.)+[A-Za-z]{2,}$"},"guard":{"type":"object","properties":{"provider":{"type":"string","enum":["anthropic","openai"]},"pinnedModel":{"type":"string","minLength":1},"apiKeyEnv":{"type":"string","pattern":"^[A-Z_][A-Z0-9_]*$"},"policyVersion":{"type":"string","minLength":1},"timeoutMs":{"type":"integer","minimum":100,"maximum":120000}},"required":["provider","pinnedModel","apiKeyEnv","policyVersion","timeoutMs"],"additionalProperties":false},"stateDir":{"type":"string","minLength":1},"requestPolicy":{"default":"code-only","type":"string","enum":["code-only","friends-of-friends","open"]},"friends":{}},"required":["enabled","relayUrl","requestPolicy"],"additionalProperties":false}},{"pluginId":"signal","channelId":"signal","label":"Signal","description":"signal-cli linked device; more setup (David Reagans: \\"Hop on Discord.\\").","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"account":{"type":"string"},"accountUuid":{"type":"string"},"transport":{"oneOf":[{"type":"object","properties":{"kind":{"type":"string","const":"managed-native"},"configPath":{"type":"string"},"url":{"type":"string","pattern":"^[Hh][Tt][Tt][Pp][Ss]?:\\\\/\\\\/(?![^/?#]*@)"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","minimum":1,"maximum":65535},"cliPath":{"type":"string"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreStories":{"type":"boolean"}},"required":["kind"],"additionalProperties":false},{"type":"object","properties":{"kind":{"type":"string","const":"external-native"},"url":{"type":"string","pattern":"^[Hh][Tt][Tt][Pp][Ss]?:\\\\/\\\\/(?![^/?#]*@)"}},"required":["kind","url"],"additionalProperties":false},{"type":"object","properties":{"kind":{"type":"string","const":"container"},"url":{"type":"string","pattern":"^[Hh][Tt][Tt][Pp][Ss]?:\\\\/\\\\/(?![^/?#]*@)"}},"required":["kind","url"],"additionalProperties":false}]},"ignoreAttachments":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"aliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"ingest":{"type":"boolean"}},"additionalProperties":false}},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"account":{"type":"string"},"accountUuid":{"type":"string"},"transport":{"oneOf":[{"type":"object","properties":{"kind":{"type":"string","const":"managed-native"},"configPath":{"type":"string"},"url":{"type":"string","pattern":"^[Hh][Tt][Tt][Pp][Ss]?:\\\\/\\\\/(?![^/?#]*@)"},"httpHost":{"type":"string"},"httpPort":{"type":"integer","minimum":1,"maximum":65535},"cliPath":{"type":"string"},"startupTimeoutMs":{"type":"integer","minimum":1000,"maximum":120000},"receiveMode":{"anyOf":[{"type":"string","const":"on-start"},{"type":"string","const":"manual"}]},"ignoreStories":{"type":"boolean"}},"required":["kind"],"additionalProperties":false},{"type":"object","properties":{"kind":{"type":"string","const":"external-native"},"url":{"type":"string","pattern":"^[Hh][Tt][Tt][Pp][Ss]?:\\\\/\\\\/(?![^/?#]*@)"}},"required":["kind","url"],"additionalProperties":false},{"type":"object","properties":{"kind":{"type":"string","const":"container"},"url":{"type":"string","pattern":"^[Hh][Tt][Tt][Pp][Ss]?:\\\\/\\\\/(?![^/?#]*@)"}},"required":["kind","url"],"additionalProperties":false}]},"ignoreAttachments":{"type":"boolean"},"sendReadReceipts":{"type":"boolean"},"aliases":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"string"}},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"ingest":{"type":"boolean"}},"additionalProperties":false}},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Signal","help":"Signal channel provider configuration including account identity and DM policy behavior. Keep account mapping explicit so routing remains stable across multi-device setups."},"dmPolicy":{"label":"Signal DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.signal.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Signal Config Writes","help":"Allow Signal to write config in response to channel events/commands (default: true)."},"account":{"label":"Signal Account","help":"Signal account identifier (phone/number handle) used to bind this channel config to a specific Signal identity. Keep this aligned with your linked device/session state.","presentation":"phone-number"},"allowFrom":{"presentation":"phone-number"},"defaultTo":{"presentation":"phone-number"},"groupAllowFrom":{"presentation":"phone-number"},"reactionAllowlist":{"presentation":"phone-number"},"accounts.*.account":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.groupAllowFrom.*":{"presentation":"phone-number"},"accounts.*.reactionAllowlist.*":{"presentation":"phone-number"},"transport":{"label":"Signal Transport","help":"Account-owned native process or external endpoint configuration. Named accounts do not inherit this value."},"transport.kind":{"label":"Signal Transport Kind","help":"Use managed-native to let OpenClaw start signal-cli, external-native for an existing native daemon, or container for signal-cli-rest-api."},"transport.configPath":{"label":"Signal CLI Config Path","help":"Optional directory passed to signal-cli via --config when the service needs a non-default signal-cli data path."},"transport.url":{"label":"Signal Transport URL","help":"Base URL for an external-native or container transport, or the connection endpoint for a managed-native daemon when it differs from the bind address."}}},{"pluginId":"slack","channelId":"slack","channelEnvVars":["SLACK_APP_TOKEN","SLACK_BOT_TOKEN","SLACK_USER_TOKEN"],"label":"Slack","description":"supported (Socket Mode).","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom', + '":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"},"nativeTaskCards":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"postAs":{"default":"bot","type":"string","enum":["bot","user"]},"mode":{"default":"socket","type":"string","enum":["socket","http","relay"]},"enterpriseOrgInstall":{"type":"boolean"},"relay":{"type":"object","properties":{"url":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"gatewayId":{"type":"string"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"default":"/slack/events","type":"string"},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"unfurlLinks":{"type":"boolean"},"unfurlMedia":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"ackReaction":{"type":"string"},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false}},"additionalProperties":false}},"typingReaction":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"interactiveReplies":{"type":"boolean"}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"type":"string"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"},"nativeTaskCards":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false},"nativeTransport":{"type":"boolean"}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"o', + 'ff"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"postAs":{"type":"string","enum":["bot","user"]},"mode":{"type":"string","enum":["socket","http","relay"]},"enterpriseOrgInstall":{"type":"boolean"},"relay":{"type":"object","properties":{"url":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"gatewayId":{"type":"string"}},"additionalProperties":false},"signingSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"appToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"userTokenReadOnly":{"default":true,"type":"boolean"},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"dangerouslyAllowNameMatching":{"type":"boolean"},"requireMention":{"type":"boolean"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"unfurlLinks":{"type":"boolean"},"unfurlMedia":{"type":"boolean"},"reactionNotifications":{"type":"string","enum":["off","own","all","allowlist"]},"reactionAllowlist":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"ackReaction":{"type":"string"},"replyToModeByChatType":{"type":"object","properties":{"direct":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"group":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"channel":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]}},"additionalProperties":false},"thread":{"type":"object","properties":{"historyScope":{"type":"string","enum":["thread","channel"]},"inheritParent":{"type":"boolean"},"initialHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"messages":{"type":"boolean"},"pins":{"type":"boolean"},"search":{"type":"boolean"},"permissions":{"type":"boolean"},"memberInfo":{"type":"boolean"},"channelInfo":{"type":"boolean"},"emojiList":{"type":"boolean"}},"additionalProperties":false},"slashCommand":{"type":"object","properties":{"enabled":{"type":"boolean"},"name":{"type":"string"},"sessionPrefix":{"type":"string"},"ephemeral":{"type":"boolean"}},"additionalProperties":false},"dm":{"type":"object","properties":{"enabled":{"type":"boolean"},"groupEnabled":{"type":"boolean"},"groupChannels":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}}},"additionalProperties":false},"channels":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"systemPrompt":{"type":"string"},"ignoreOtherMentions":{"type":"boolean"},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"allowBots":{"anyOf":[{"type":"boolean"},{"type":"string","const":"mentions"}]},"botLoopProtection":{"type":"object","properties":{"enabled":{"type":"boolean"},"maxEventsPerWindow":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"windowSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"cooldownSeconds":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"additionalProperties":false},"users":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"presenceEvents":{"type":"object","properties":{"mode":{"type":"string","enum":["off","auto","on"]}},"additionalProperties":false}},"additionalProperties":false}},"typingReaction":{"type":"string"}},"required":["userTokenReadOnly"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy","postAs","mode","webhookPath","userTokenReadOnly"],"additionalProperties":false},"uiHints":{"":{"label":"Slack","help":"Slack channel provider configuration for bot/app tokens, streaming behavior, and DM policy controls. Keep token handling and thread behavior explicit to avoid noisy workspace interactions."},"enterpriseOrgInstall":{"label":"Slack Enterprise Grid Org Install","help":"Enable only for an Enterprise Grid org-wide bot installation. OpenClaw verifies the token with Slack auth.test at startup; DMs must be disabled or use dmPolicy=\\"open\\" with allowFrom=[\\"*\\"]."},"postAs":{"label":"Slack Identity","help":"Select \\"bot\\" (default) for the classic Slack app/bot identity or \\"user\\" to post as the authorizing human through a user token while the app carries event transport."},"dmPolicy":{"label":"Slack DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.slack.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Slack Config Writes","help":"Allow Slack to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"Slack Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Slack channel IDs. Native Slack @mentions still trigger even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Slack Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Slack Mention Pattern Allowlist","help":"Slack channel IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Slack Mention Pattern Denylist","help":"Slack channel IDs where configured regex mention patterns are disabled. Native @mentions still trigger."},"commands.native":{"label":"Slack Native Commands","help":"Override native commands for Slack (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Slack Native Skill Commands","help":"Override native skill commands for Slack (bool or \\"auto\\")."},"implicitMentions":{"label":"Slack Implicit Mentions","help":"Control which Slack reply, quote, and thread-participation signals count as mentions. Unset flags preserve the channel defaults."},"implicitMentions.replyToBot":{"label":"Slack Replies to Bot","help":"Treat replies to the bot\'s own messages as implicit mentions when the channel reports that signal."},"implicitMentions.quotedBot":{"label":"Slack Quoted Bot Messages","help":"Treat messages quoting the bot as implicit mentions when the channel reports that signal."},"implicitMentions.threadParticipation":{"label":"Slack Thread Participation","help":"Treat follow-ups in threads where the bot participated as implicit mentions when the channel reports that signal."},"allowBots":{"label":"Slack Allow Bot Messages","help":"Allow bot-authored messages to trigger Slack replies (default: false)."},"botLoopProtection":{"label":"Slack Bot Loop Protection","help":"Sliding-window guard for Slack bot-to-bot loops. Default is enabled whenever allowBots lets bot-authored messages reach dispatch."},"botLoopProtection.enabled":{"label":"Slack Bot Loop Protection Enabled","help":"Enable the bot-pair loop guard. Defaults to true when allowBots is true or \\"mentions\\", and false when bot messages are ignored."},"botLoopProtection.maxEventsPerWindow":{"label":"Slack Bot Loop Events per Window","help":"Maximum accepted bot-pair messages within the sliding window before suppression starts. Default: 20."},"botLoopProtection.windowSeconds":{"label":"Slack Bot Loop Window Seconds","help":"Sliding window length for counting bot-pair messages. Default: 60."},"botLoopProtection.cooldownSeconds":{"label":"Slack Bot Loop Cooldown Seconds","help":"How long to suppress the bot pair after it exceeds the budget. Default: 60."},"relay":{"label":"Slack Relay Mode","help":"Relay-delivered Slack events. Use with mode=\\"relay\\" when openclaw-slack-router owns the Slack Socket Mode connection."},"relay.url":{"label":"Slack Relay URL","help":"Full websocket URL for openclaw-slack-router. Include the route path, for example ws://127.0.0.1:8081/gateway/ws."},"relay.authToken":{"label":"Slack Relay Auth Token","help":"Bearer token used by this gateway to authenticate its reverse websocket connection to openclaw-slack-router."},"relay.gatewayId":{"label":"Slack Relay Gateway ID","help":"Destination id that openclaw-slack-router uses when routing user-group mentions to this gateway."},"botToken":{"label":"Slack Bot Token","help":"Slack bot token used for standard chat actions in the configured workspace. Keep this credential scoped and rotate if workspace app permissions change."},"appToken":{"label":"Slack App Token","help":"Slack app-level token used for Socket Mode connections and event transport when enabled. Use least-privilege app scopes and store this token as a secret."},"userToken":{"label":"Slack User Token","help":"Optional Slack user token for workflows requiring user-context API access beyond bot permissions. Use sparingly and audit scopes because this token can carry broader authority."},"userTokenReadOnly":{"label":"Slack User Token Read Only","help":"When true, treat configured Slack user token usage as read-only helper behavior where possible. Keep enabled if you only need supplemental reads without user-context writes."},"capabilities.interactiveReplies":{"label":"Slack Interactive Replies","help":"Enable agent-authored Slack interactive reply directives (`[[slack_buttons: ...]]`, `[[slack_select: ...]]`). Default: false."},"execApprovals":{"label":"Slack Exec Approvals","help":"Slack-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for this workspace account."},"presenceEvents":{"label":"Slack Presence Events","help":"Poll observed human participants and wake the routed agent on away-to-active transitions. Default: \\"off\\"."},"presenceEvents.mode":{"label":"Slack Presence Event Mode","help":"\\"off\\" disables polling; \\"auto\\" covers DMs, MPIMs, and recent threads with up to 8 observed people; \\"on\\" also covers larger threads and top-level channels."},"channels.*.presenceEvents.mode":{"label":"Slack Channel Presence Event Mode","help":"Override presence events for one Slack channel. Use \\"on\\" to include large threads or top-level channel sessions."},"execApprovals.enabled":{"label":"Slack Exec Approvals Enabled","help":"Controls Slack native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Slack Exec Approval Approvers","help":"Slack user IDs allowed to approve exec requests for this workspace accoun', + 't. Use Slack user IDs or user targets such as `U123`, `user:U123`, or `<@U123>`. If you leave this unset, OpenClaw falls back to commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Slack Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Slack exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Slack."},"execApprovals.sessionFilter":{"label":"Slack Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Slack approval routing is used. Use narrow patterns so Slack approvals only appear for intended sessions."},"execApprovals.target":{"label":"Slack Exec Approval Target","help":"Controls where Slack approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Slack chat/thread, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted channels."},"streaming":{"label":"Slack Streaming Mode","help":"Unified Slack stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\". Legacy boolean/streamMode keys are auto-mapped."},"streaming.mode":{"label":"Slack Streaming Mode","help":"Canonical Slack preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\"."},"streaming.chunkMode":{"label":"Slack Chunk Mode","help":"Chunking mode for outbound Slack text delivery: \\"length\\" (default) or \\"newline\\"."},"streaming.block.enabled":{"label":"Slack Block Streaming Enabled","help":"Enable chunked block-style Slack preview delivery when channels.slack.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Slack Block Streaming Coalesce","help":"Merge streamed Slack block replies before final delivery."},"streaming.nativeTransport":{"label":"Slack Native Streaming","help":"Enable native Slack text streaming (chat.startStream/chat.appendStream/chat.stopStream) when channels.slack.streaming.mode is partial (default: true). Native streaming and Slack assistant thread status require a reply thread target; top-level DMs can still use draft post-and-edit preview streaming."},"streaming.preview.toolProgress":{"label":"Slack Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true). Set false to hide interim tool updates while the draft preview stays active."},"streaming.preview.commandText":{"label":"Slack Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Slack Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Slack Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use OpenClaw built-in progress labels."},"streaming.progress.maxLines":{"label":"Slack Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Slack Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.render":{"label":"Slack Progress Renderer","help":"Progress draft renderer: \\"text\\" uses one portable text body; \\"rich\\" renders structured Slack Block Kit fields with the same text fallback."},"streaming.progress.nativeTaskCards":{"label":"Slack Native Progress Task Cards","help":"Opt in to Slack native task-card progress updates when channels.slack.streaming.mode=\\"progress\\" and streaming.nativeTransport is enabled. Default: false."},"streaming.progress.toolProgress":{"label":"Slack Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Slack Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"thread.historyScope":{"label":"Slack Thread History Scope","help":"Scope for Slack thread history context (\\"thread\\" isolates per thread; \\"channel\\" reuses channel history)."},"thread.inheritParent":{"label":"Slack Thread Parent Inheritance","help":"If true, Slack thread sessions inherit the parent channel transcript (default: false)."},"thread.initialHistoryLimit":{"label":"Slack Thread Initial History Limit","help":"Maximum number of existing Slack thread messages to fetch when starting a new thread session (default: 20, set to 0 to disable)."}}},{"pluginId":"sms","channelId":"sms","order":88,"channelEnvVars":["SMS_ALLOWED_USERS","SMS_PUBLIC_WEBHOOK_URL","SMS_WEBHOOK_PATH","TWILIO_ACCOUNT_SID","TWILIO_AUTH_TOKEN","TWILIO_MESSAGING_SERVICE_SID","TWILIO_PHONE_NUMBER","TWILIO_SMS_FROM"],"label":"SMS","description":"Twilio-backed SMS with inbound webhooks and outbound replies.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"accountSid":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"fromNumber":{"type":"string"},"messagingServiceSid":{"type":"string"},"defaultTo":{"type":"string"},"webhookPath":{"type":"string"},"publicWebhookUrl":{"type":"string"},"dangerouslyDisableSignatureValidation":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"accountSid":{"type":"string"},"authToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"fromNumber":{"type":"string"},"messagingServiceSid":{"type":"string"},"defaultTo":{"type":"string"},"webhookPath":{"type":"string"},"publicWebhookUrl":{"type":"string"},"dangerouslyDisableSignatureValidation":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991}},"required":["dmPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"SMS","help":"Twilio SMS channel configuration for inbound webhooks and outbound text replies."},"accountSid":{"label":"Twilio Account SID","help":"Twilio Account SID used for SMS outbound API calls."},"authToken":{"label":"Twilio Auth Token","help":"Twilio Auth Token used to sign webhook validation and SMS outbound API calls."},"fromNumber":{"label":"SMS From Number","help":"Twilio SMS-capable phone number in E.164 format, for example +15551234567.","presentation":"phone-number"},"messagingServiceSid":{"label":"Twilio Messaging Service SID","help":"Twilio Messaging Service SID to use instead of a dedicated fromNumber."},"defaultTo":{"label":"SMS Default To Number","help":"Optional default outbound phone number used when a send flow omits an explicit SMS target.","presentation":"phone-number"},"publicWebhookUrl":{"label":"SMS Public Webhook URL","help":"Public URL configured in Twilio for incoming messages. Must match Twilio\'s signed URL exactly."},"webhookPath":{"label":"SMS Webhook Path","help":"Gateway HTTP path that receives Twilio incoming-message webhooks. Use a distinct path per account."},"dmPolicy":{"label":"SMS DM Policy","help":"Direct SMS access control (\\"pairing\\" recommended). \\"open\\" requires channels.sms.allowFrom=[\\"*\\"]."},"allowFrom":{"label":"SMS Allow From","help":"Allowed sender phone numbers in E.164 format, or * when dmPolicy is open.","presentation":"phone-number"},"accounts.*.fromNumber":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"textChunkLimit":{"label":"SMS Text Chunk Limit","help":"Maximum characters per outbound SMS chunk before OpenClaw splits long replies."}}},{"pluginId":"synology-chat","channelId":"synology-chat","order":90,"channelEnvVars":["OPENCLAW_BOT_NAME","SYNOLOGY_ALLOWED_USER_IDS","SYNOLOGY_CHAT_INCOMING_URL","SYNOLOGY_CHAT_TOKEN","SYNOLOGY_NAS_HOST","SYNOLOGY_RATE_LIMIT"],"label":"Synology Chat","description":"Connect your Synology NAS Chat to OpenClaw with full agent capabilities.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"dangerouslyAllowNameMatching":{"type":"boolean"},"dangerouslyAllowInheritedWebhookPath":{"type":"boolean"}},"additionalProperties":{}}},{"pluginId":"telegram","channelId":"telegram","channelEnvVars":["TELEGRAM_BOT_TOKEN"],"label":"Telegram","description":"simplest way to get started — register a bot with @BotFather and get going.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":', + '"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"richMessages":{"type":"boolean"},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"ackReaction":{"type":"string"},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"capabilities":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"object","properties":{"inlineButtons":{"type":"string","enum":["off","dm","group","all","allowlist"]}},"additionalProperties":false}]},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"defaultTo":{"anyOf":[{"type":"string"},{"type":"number"}]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"mode":{"type":"string","enum":["off","partial","block","progress"]},"chunkMode":{"type":"string","enum":["length","newline"]},"preview":{"type":"object","properties":{"chunk":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"breakPreference":{"anyOf":[{"type":"string","const":"paragraph"},{"type":"string","const":"newline"},{"type":"string","const":"sentence"}]}},"additionalProperties":false},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]}},"additionalProperties":false},"progress":{"type":"object","properties":{"label":{"anyOf":[{"type":"string"},{"type":"boolean","const":false}]},"labels":{"type":"array","items":{"type":"string"}},"maxLines":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxLineChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"render":{"type":"string","enum":["text","rich"]},"toolProgress":{"type":"boolean"},"commandText":{"type":"string","enum":["raw","status"]},"commentary":{"type":"boolean"},"narration":{"type":"boolean"}},"additionalProperties":false},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"number","exclusiveMinimum":0},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"execApprovals":{"type":"object","properties":{"enabled":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"approvers":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"agentFilter":{"type":"array","items":{"type":"string"}},"sessionFilter":{"type":"array","items":{"type":"string"}},"target":{"type":"string","enum":["dm","channel","both"]}},"additionalProperties":false},"commands":{"type":"object","properties":{"native":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]},"nativeSkills":{"anyOf":[{"type":"boolean"},{"type":"string","const":"auto"}]}},"additionalProperties":false},"customCommands":{"type":"array","items":{"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"}},"required":["command","description"],"additionalProperties":false}},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"topics":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"ingest":{"type":"boolean"},"disableAudioPreflight":{"type":"boolean"},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"skills":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"systemPrompt":{"type":"string"},"agentId":{"type":"string"},"errorPolicy":{"type":"string","enum":["always","once","silent"]}},"additionalProperties":false}},"errorPolicy":{"type":"string","enum":["always', + '","once","silent"]},"requireTopic":{"type":"boolean"},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"additionalProperties":false}},"richMessages":{"type":"boolean"},"network":{"type":"object","properties":{"autoSelectFamily":{"type":"boolean"},"dnsResultOrder":{"type":"string","enum":["ipv4first","verbatim"]},"dangerouslyAllowPrivateNetwork":{"description":"Dangerous opt-in for trusted Telegram fake-IP or transparent-proxy environments where api.telegram.org resolves to private/internal/special-use addresses during media downloads.","type":"boolean"}},"additionalProperties":false},"proxy":{"type":"string"},"webhookUrl":{"description":"Public HTTPS webhook URL registered with Telegram for inbound updates. This must be internet-reachable and requires channels.telegram.webhookSecret.","type":"string"},"webhookSecret":{"description":"Secret token sent to Telegram during webhook registration and verified on inbound webhook requests. Telegram returns this value for verification; this is not the gateway auth token and not the bot token.","anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"description":"Local webhook route path served by the gateway listener. Defaults to /telegram-webhook.","type":"string"},"webhookHost":{"description":"Local bind host for the webhook listener. Defaults to 127.0.0.1; keep loopback unless you intentionally expose direct ingress.","type":"string"},"webhookPort":{"description":"Local bind port for the webhook listener. Defaults to 8787; set to 0 to let the OS assign an ephemeral port.","type":"integer","minimum":0,"maximum":9007199254740991},"webhookCertPath":{"description":"Path to the self-signed certificate (PEM) to upload to Telegram during webhook registration. Required for self-signed certs (direct IP or no domain).","type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"poll":{"type":"boolean"},"deleteMessage":{"type":"boolean"},"editMessage":{"type":"boolean"},"sticker":{"type":"boolean"},"createForumTopic":{"type":"boolean"},"editForumTopic":{"type":"boolean"}},"additionalProperties":false},"threadBindings":{"type":"object","properties":{"enabled":{"type":"boolean"},"idleHours":{"type":"number","minimum":0},"maxAgeHours":{"type":"number","minimum":0},"spawnSessions":{"type":"boolean"},"defaultSpawnContext":{"type":"string","enum":["isolated","fork"]}},"additionalProperties":false},"reactionNotifications":{"type":"string","enum":["off","own","all"]},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"ackReaction":{"type":"string"},"linkPreview":{"type":"boolean"},"silentErrorReplies":{"type":"boolean"},"errorPolicy":{"type":"string","enum":["always","once","silent"]},"apiRoot":{"type":"string","format":"uri"},"trustedLocalFileRoots":{"description":"Trusted local filesystem roots for self-hosted Telegram Bot API absolute file_path values. Only absolute paths under these roots are read directly; all other absolute paths are rejected.","type":"array","items":{"type":"string"}},"autoTopicLabel":{"anyOf":[{"type":"boolean"},{"type":"object","properties":{"enabled":{"type":"boolean"},"prompt":{"type":"string"}},"additionalProperties":false}]}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["dmPolicy","groupPolicy"],"additionalProperties":false},"uiHints":{"":{"label":"Telegram","help":"Telegram channel provider configuration including auth tokens, retry behavior, and message rendering controls. Use this section to tune bot behavior for Telegram-specific API semantics."},"customCommands":{"label":"Telegram Custom Commands","help":"Additional Telegram bot menu commands (merged with native; conflicts ignored)."},"botToken":{"label":"Telegram Bot Token","help":"Telegram bot token used to authenticate Bot API requests for this account/provider config. Use secret/env substitution and rotate tokens if exposure is suspected."},"dmPolicy":{"label":"Telegram DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.telegram.allowFrom=[\\"*\\"]."},"configWrites":{"label":"Telegram Config Writes","help":"Allow Telegram to write config in response to channel events/commands (default: true)."},"mentionPatterns":{"label":"Telegram Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected Telegram group chat IDs or chatId:topic:threadId topic IDs. Native Telegram bot mentions still trigger even when regex patterns are denied."},"mentionPatterns.mode":{"label":"Telegram Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"Telegram Mention Pattern Allowlist","help":"Telegram group chat IDs or chatId:topic:threadId topic IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"Telegram Mention Pattern Denylist","help":"Telegram group chat IDs or chatId:topic:threadId topic IDs where configured regex mention patterns are disabled. Native bot mentions still trigger."},"commands.native":{"label":"Telegram Native Commands","help":"Override native commands for Telegram (bool or \\"auto\\")."},"commands.nativeSkills":{"label":"Telegram Native Skill Commands","help":"Override native skill commands for Telegram (bool or \\"auto\\")."},"streaming":{"label":"Telegram Streaming Mode","help":"Unified Telegram stream preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\"). \\"progress\\" keeps a single editable progress draft until final delivery. Legacy boolean/streamMode keys are detected; run doctor --fix to migrate."},"streaming.mode":{"label":"Telegram Streaming Mode","help":"Canonical Telegram preview mode: \\"off\\" | \\"partial\\" | \\"block\\" | \\"progress\\" (default: \\"partial\\")."},"streaming.chunkMode":{"label":"Telegram Chunk Mode","help":"Chunking mode for outbound Telegram text delivery: \\"length\\" (default) or \\"newline\\"."},"richMessages":{"label":"Telegram Rich Messages","help":"Opt into Bot API 10.1 rich text sends and edits, including native tables and rich media. Default: false because some current Telegram clients render these messages as unsupported."},"streaming.block.enabled":{"label":"Telegram Block Streaming Enabled","help":"Enable chunked block-style Telegram preview delivery when channels.telegram.streaming.mode=\\"block\\"."},"streaming.block.coalesce":{"label":"Telegram Block Streaming Coalesce","help":"Merge streamed Telegram block replies before sending final delivery."},"streaming.preview.chunk.minChars":{"label":"Telegram Draft Chunk Min Chars","help":"Minimum chars before emitting a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.maxChars":{"label":"Telegram Draft Chunk Max Chars","help":"Target max size for a Telegram block preview chunk when channels.telegram.streaming.mode=\\"block\\"."},"streaming.preview.chunk.breakPreference":{"label":"Telegram Draft Chunk Break Preference","help":"Preferred breakpoints for Telegram draft chunks (paragraph | newline | sentence)."},"streaming.preview.toolProgress":{"label":"Telegram Draft Tool Progress","help":"Show tool/progress activity in the live draft preview message (default: true when preview streaming is active). Set false to keep tool updates out of the edited Telegram preview."},"streaming.preview.commandText":{"label":"Telegram Draft Command Text","help":"Command/exec detail in preview tool-progress lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.label":{"label":"Telegram Progress Label","help":"Initial progress draft title. Use \\"auto\\" for built-in single-word labels, a custom string, or false to hide the title."},"streaming.progress.labels":{"label":"Telegram Progress Label Pool","help":"Candidate labels for streaming.progress.label=\\"auto\\". Leave unset to use the built-in \\"Working\\" label."},"streaming.progress.maxLines":{"label":"Telegram Progress Max Lines","help":"Maximum number of compact progress lines to keep below the draft label (default: 8)."},"streaming.progress.maxLineChars":{"label":"Telegram Progress Max Line Chars","help":"Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes."},"streaming.progress.toolProgress":{"label":"Telegram Progress Tool Lines","help":"Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery."},"streaming.progress.commandText":{"label":"Telegram Progress Command Text","help":"Command/exec detail in progress draft lines: \\"raw\\" preserves released behavior; \\"status\\" shows only the tool label."},"streaming.progress.commentary":{"label":"Telegram Progress Commentary","help":"Show assistant commentary/preamble text in the temporary progress draft. Final answer delivery is unchanged."},"network.autoSelectFamily":{"label":"Telegram autoSelectFamily","help":"Override Node autoSelectFamily for Telegram (true=enable, false=disable)."},"network.dangerouslyAllowPrivateNetwork":{"label":"Telegram Dangerously Allow Private Network","help":"Dangerous opt-in for trusted fake-IP or transparent-proxy environments where Telegram media downloads resolve api.telegram.org to private/internal/special-use addresses."},"silentErrorReplies":{"label":"Telegram Silent Error Replies","help":"When true, Telegram bot replies marked as errors are sent silently (no notification sound). Default: false."},"apiRoot":{"label":"Telegram API Root URL","help":"Custom Telegram Bot API root URL. Use the API root only (for example https://api.telegram.org), not a full /bot endpoint. Use for self-hosted Bot API servers (https://github.com/tdlib/telegram-bot-api) or reverse proxies in regions where api.telegram.org is blocked."},"trustedLocalFileRoots":{"label":"Telegram Trusted Local File Roots","help":"Trusted local filesystem roots for self-hosted Telegram Bot API file_path values. Exact in-root paths are read directly; container paths under /var/lib/telegram-bot-api can map into a host volume mount. Other absolute paths are rejected."},"autoTopicLabel":{"label":"Telegram Auto Topic Label","help":"Auto-rename DM forum topics on first message using LLM. Default: true. Set to false to disable, or use object form { enabled: true, prompt: \'...\' } for custom prompt."},"autoTopicLabel.enabled":{"label":"Telegram Auto Topic Label Enabled","help":"Whether auto topic labeling is enabled. Default: true."},"autoTopicLabel.prompt":{"label":"Telegram Auto Topic Label Prompt","help":"Custom prompt for LLM-based topic naming. The user message is appended after the prompt."},"capabilities.inlineButtons":{"label":"Telegram Inline Buttons","help":"Enable Telegram inline button components for supported command and interaction surfaces. Disable if your deployment needs plain-text-only compatibility behavior."},"execApprovals":{"label":"Telegram Exec Approvals","help":"Telegram-native exec approval routing and approver authorization. When unset, OpenClaw auto-enables DM-first native approvals if approvers can be resolved for the selected bot account."},"execApprovals.enabled":{"label":"Telegram Exec Approvals Enabled","help":"Controls Telegram native exec approvals for this account: unset or \\"auto\\" enables DM-first native approvals when approvers can be resolved, true forces native approvals on, and false disables them."},"execApprovals.approvers":{"label":"Telegram Exec Approval Approvers","help":"Telegram user IDs allowed to approve exec requests for this bot account. Use numeric Telegram user IDs. If you leave this unset, OpenClaw falls back to numeric owner IDs inferred from commands.ownerAllowFrom when possible."},"execApprovals.agentFilter":{"label":"Telegram Exec Approval Agent Filter","help":"Optional allowlist of agent IDs eligible for Telegram exec approvals, for example `[\\"main\\", \\"ops-agent\\"]`. Use this to keep approval prompts scoped to the agents you actually operate from Telegram."},"execApprovals.sessionFilter":{"label":"Telegram Exec Approval Session Filter","help":"Optional session-key filters matched as substring or regex-style patterns before Telegram approval routing is used. Use narrow patterns so Telegram approvals only appear for intended sessions."},"execApprovals.target":{"label":"Telegram Exec Approval Target","help":"Controls where Telegram approval prompts are sent: \\"dm\\" sends to approver DMs (default), \\"channel\\" sends to the originating Telegram chat/topic, and \\"both\\" sends to both. Channel delivery exposes the command text to the chat, so only use it in trusted groups/topics."},"threadBindings.enabled":{"label":"Telegram Thread Binding Enabled","help":"Enable Telegram conversation binding features (/focus, /unfocus, /agents, and /session idle|max-age). Overrides session.threadBindings.enabled when set."},"threadBindings.idleHours":{"label":"Telegram Thread Binding Idle Timeout (hours)","help":"Inactivity window in hours for Telegram bound sessions. Set 0 to disable idle auto-unfocus (default: 24). Overrides session.threadBindings.idleHours when set."},"threadBindings.maxAgeHours":{"label":"Telegram Thread Binding Max Age (hours)","help":"Optional hard max age in hours for Telegram bound sessions. Set 0 to disable hard cap (default: 0). Overrides session.threadBindings.maxAgeHours when set."},"threadBindings.spawnSessions":{"label":"Telegram Thread-Bound Session Spawn","help":"Allow sessions_spawn(thread=true) and ACP thread spawns to auto-bind Telegram current conversations when supported."},"threadBindings.defaultSpawnContext":{"label":"Telegram Thread Spawn Context","help":"Default native subagent context for thread-bound spawns. \\"fork\\" starts from the requester transcript; \\"isolated\\" starts clean. Default: \\"fork\\"."}}},{"pluginId":"tlon","channelId":"tlon","order":90,"label":"Tlon","description":"decentralized messaging on Urbit; install the plugin to enable.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1},"authorization":{"type":"object","properties":{"channelRules":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"mode":{"type":"string","enum":["restricted","open"]},"allowedShips":{"type":"array","items":{"type":"string","minLength":1}}},"additionalProperties":false}}},"additionalProperties":false},"defa', + 'ultAuthorizedShips":{"type":"array","items":{"type":"string","minLength":1}},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"ship":{"type":"string","minLength":1},"url":{"type":"string"},"code":{"type":"string"},"network":{"type":"object","properties":{"dangerouslyAllowPrivateNetwork":{"type":"boolean"}},"additionalProperties":false},"groupChannels":{"type":"array","items":{"type":"string","minLength":1}},"dmAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"groupInviteAllowlist":{"type":"array","items":{"type":"string","minLength":1}},"autoDiscoverChannels":{"type":"boolean"},"showModelSignature":{"type":"boolean"},"responsePrefix":{"type":"string"},"implicitMentions":{"type":"object","properties":{"replyToBot":{"type":"boolean"},"quotedBot":{"type":"boolean"},"threadParticipation":{"type":"boolean"}},"additionalProperties":false},"autoAcceptDmInvites":{"type":"boolean"},"autoAcceptGroupInvites":{"type":"boolean"},"ownerShip":{"type":"string","minLength":1}},"additionalProperties":false}}},"additionalProperties":false},"uiHints":{"implicitMentions":{"label":"Tlon Implicit Mentions","help":"Control which Tlon reply, quote, and thread-participation signals count as mentions. Unset flags preserve the channel defaults."},"implicitMentions.replyToBot":{"label":"Tlon Replies to Bot","help":"Treat replies to the bot\'s own messages as implicit mentions when the channel reports that signal."},"implicitMentions.quotedBot":{"label":"Tlon Quoted Bot Messages","help":"Treat messages quoting the bot as implicit mentions when the channel reports that signal."},"implicitMentions.threadParticipation":{"label":"Tlon Thread Participation","help":"Treat follow-ups in threads where the bot participated as implicit mentions when the channel reports that signal."}}},{"pluginId":"twitch","channelId":"twitch","aliases":["twitch-chat"],"channelEnvVars":["OPENCLAW_TWITCH_ACCESS_TOKEN"],"label":"Twitch","description":"Twitch chat integration","schema":{"$schema":"http://json-schema.org/draft-07/schema#","anyOf":[{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false},{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"defaultAccount":{"type":"string"},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"username":{"type":"string"},"accessToken":{"type":"string"},"clientId":{"type":"string"},"channel":{"type":"string","minLength":1},"enabled":{"type":"boolean"},"allowFrom":{"type":"array","items":{"type":"string"}},"allowedRoles":{"type":"array","items":{"type":"string","enum":["moderator","owner","vip","subscriber","all"]}},"requireMention":{"type":"boolean"},"responsePrefix":{"type":"string"},"clientSecret":{"type":"string"},"refreshToken":{"type":"string"},"expiresIn":{"anyOf":[{"type":"number"},{"type":"null"}]},"obtainmentTimestamp":{"type":"number"}},"required":["username","accessToken","channel"],"additionalProperties":false}}},"required":["accounts"],"additionalProperties":false}]}},{"pluginId":"whatsapp","channelId":"whatsapp","label":"WhatsApp","description":"works with your own number; recommend a separate phone + eSIM.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"default":"pairing","type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"default":50,"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"sendReadReceipts":{"type":"boolean"},"selfChatMode":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"default":0,"type":"integer","minimum":0,"maximum":9007199254740991},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"accounts":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"capabilities":{"type":"array","items":{"type":"string"}},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"configWrites":{"type":"boolean"},"enabled":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"type":"string"}},"defaultTo":{"type":"string"},"groupAllowFrom":{"type":"array","items":{"type":"string"}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"mentionPatterns":{"type":"object","properties":{"mode":{"anyOf":[{"type":"string","const":"allow"},{"type":"string","const":"deny"}]},"allowIn":{"type":"array","items":{"type":"string"}},"denyIn":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"contextVisibility":{"type":"string","enum":["all","allowlist","allowlist_quote"]},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dmHistoryLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"dms":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"textChunkLimit":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"streaming":{"type":"object","properties":{"chunkMode":{"type":"string","enum":["length","newline"]},"block":{"type":"object","properties":{"enabled":{"type":"boolean"},"coalesce":{"type":"object","properties":{"minChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"maxChars":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idleMs":{"type":"integer","minimum":0,"maximum":9007199254740991}},"additionalProperties":false}},"additionalProperties":false}},"additionalProperties":false},"heartbeatVisibility":{"type":"object","properties":{"showOk":{"type":"boolean"},"showAlerts":{"type":"boolean"},"useIndicator":{"type":"boolean"}},"additionalProperties":false},"healthMonitor":{"type":"object","properties":{"enabled":{"type":"boolean"}},"additionalProperties":false},"responsePrefix":{"type":"string"},"mediaMaxMb":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"replyToMode":{"anyOf":[{"type":"string","const":"off"},{"type":"string","const":"first"},{"type":"string","const":"all"},{"type":"string","const":"batched"}]},"sendReadReceipts":{"type":"boolean"},"selfChatMode":{"type":"boolean"},"groups":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"toolsBySender":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false}},"systemPrompt":{"type":"string"}},"additionalProperties":false}},"direct":{"type":"object","propertyNames":{"type":"string"},"additionalProperties":{"type":"object","properties":{"systemPrompt":{"type":"string"}},"additionalProperties":false}},"reactionLevel":{"type":"string","enum":["off","ack","minimal","extensive"]},"debounceMs":{"type":"integer","minimum":0,"maximum":9007199254740991},"pluginHooks":{"type":"object","properties":{"messageReceived":{"type":"boolean"}},"additionalProperties":false},"name":{"type":"string"},"authDir":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"},"actions":{"type":"object","properties":{"reactions":{"type":"boolean"},"sendMessage":{"type":"boolean"},"polls":{"type":"boolean"},"calls":{"type":"boolean"}},"additionalProperties":false}},"required":["dmPolicy","groupPolicy","mediaMaxMb","debounceMs"],"additionalProperties":false},"uiHints":{"":{"label":"WhatsApp","help":"WhatsApp channel provider configuration for access policy and message batching behavior. Use this section to tune responsiveness and direct-message routing safety for WhatsApp chats."},"dmPolicy":{"label":"WhatsApp DM Policy","help":"Direct message access control (\\"pairing\\" recommended). \\"open\\" requires channels.whatsapp.allowFrom=[\\"*\\"]."},"allowFrom":{"presentation":"phone-number"},"defaultTo":{"presentation":"phone-number"},"groupAllowFrom":{"presentation":"phone-number"},"accounts.*.allowFrom.*":{"presentation":"phone-number"},"accounts.*.defaultTo":{"presentation":"phone-number"},"accounts.*.groupAllowFrom.*":{"presentation":"phone-number"},"selfChatMode":{"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number)."},"debounceMs":{"label":"WhatsApp Message Debounce (ms)","help":"Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable)."},"configWrites":{"label":"WhatsApp Config Writes","help":"Allow WhatsApp to write config in response to channel events/commands (default: true)."},"actions.calls":{"label":"WhatsApp Voice Calls","help":"Expose the experimental requester-bound WhatsApp voice-call tool. Default: false. Requires a separately paired MeowCaller CLI."},"mentionPatterns":{"label":"WhatsApp Mention Pattern Policy","help":"Scopes configured groupChat mentionPatterns to selected WhatsApp conversation IDs such as 123@g.us."},"mentionPatterns.mode":{"label":"WhatsApp Mention Pattern Mode","help":"\\"allow\\" enables configured regex mention patterns unless denyIn matches; \\"deny\\" disables them unless allowIn matches."},"mentionPatterns.allowIn":{"label":"WhatsApp Mention Pattern Allowlist","help":"WhatsApp conversation IDs where configured regex mention patterns are enabled when mode is deny."},"mentionPatterns.denyIn":{"label":"WhatsApp Mention Pattern Denylist","help":"WhatsApp conversation IDs where configured regex mention patterns are disabled."}},"unsupportedSecretRefSurfacePatterns":["channels.whatsapp.accounts.*.creds.json","channels.whatsapp.creds.json"]},{"pluginId":"zalo","channelId":"zalo","aliases":["zl"],"order":80,"channelEnvVars":["ZALO_BOT_TOKEN","ZALO_WEBHOOK_SECRET"],"label":"Zalo","description":"Vietnam-focused messaging platform with Bot API.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","prop', + 'erties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"botToken":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"tokenFile":{"type":"string"},"webhookUrl":{"type":"string"},"webhookSecret":{"anyOf":[{"type":"string"},{"oneOf":[{"type":"object","properties":{"source":{"type":"string","const":"env"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string","pattern":"^[A-Z][A-Z0-9_]{0,127}$"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"file"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false},{"type":"object","properties":{"source":{"type":"string","const":"exec"},"provider":{"type":"string","pattern":"^[a-z][a-z0-9_-]{0,63}$"},"id":{"type":"string"}},"required":["source","provider","id"],"additionalProperties":false}]}]},"webhookPath":{"type":"string"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"type":"string","enum":["open","disabled","allowlist"]},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"mediaMaxMb":{"type":"number"},"proxy":{"type":"string"},"responsePrefix":{"type":"string"}},"additionalProperties":false}},"defaultAccount":{"type":"string"}},"additionalProperties":false}},{"pluginId":"zalouser","channelId":"zalouser","aliases":["zlu"],"order":85,"channelEnvVars":["ZALOUSER_PROFILE","ZCA_PROFILE"],"label":"Zalo Personal","description":"Zalo personal account via QR code login.","schema":{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"enabled":{"type":"boolean"}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"},"accounts":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"name":{"type":"string"},"enabled":{"type":"boolean"},"markdown":{"type":"object","properties":{"tables":{"type":"string","enum":["off","bullets","code","block"]}},"additionalProperties":false},"profile":{"type":"string"},"dangerouslyAllowNameMatching":{"type":"boolean"},"dmPolicy":{"type":"string","enum":["pairing","allowlist","open","disabled"]},"allowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"historyLimit":{"type":"integer","minimum":0,"maximum":9007199254740991},"groupAllowFrom":{"type":"array","items":{"anyOf":[{"type":"string"},{"type":"number"}]}},"groupPolicy":{"default":"allowlist","type":"string","enum":["open","disabled","allowlist"]},"groups":{"type":"object","properties":{},"additionalProperties":{"type":"object","properties":{"requireMention":{"type":"boolean"},"tools":{"type":"object","properties":{"allow":{"type":"array","items":{"type":"string"}},"alsoAllow":{"type":"array","items":{"type":"string"}},"deny":{"type":"array","items":{"type":"string"}}},"additionalProperties":false},"enabled":{"type":"boolean"}},"additionalProperties":false}},"messagePrefix":{"type":"string"},"responsePrefix":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}},"defaultAccount":{"type":"string"}},"required":["groupPolicy"],"additionalProperties":false}}]', ].join(""); export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = JSON.parse( diff --git a/src/config/channel-configured.test.ts b/src/config/channel-configured.test.ts index 02f13867b838..3e5ee219aa50 100644 --- a/src/config/channel-configured.test.ts +++ b/src/config/channel-configured.test.ts @@ -50,7 +50,7 @@ describe("isChannelConfigured", () => { { channels: { signal: { - httpUrl: "http://127.0.0.1:8080", + transport: { kind: "managed-native", httpPort: 8080 }, }, }, }, diff --git a/src/config/types.signal.ts b/src/config/types.signal.ts index ccd5fb43e2d9..dda1d1f67760 100644 --- a/src/config/types.signal.ts +++ b/src/config/types.signal.ts @@ -9,7 +9,34 @@ import type { GroupToolPolicyBySenderConfig, GroupToolPolicyConfig } from "./typ export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist"; export type SignalReactionLevel = "off" | "ack" | "minimal" | "extensive"; -export type SignalApiMode = "auto" | "native" | "container"; +export type SignalTransportConfig = + | { + kind: "managed-native"; + /** Optional signal-cli config directory path (passed as --config). */ + configPath?: string; + /** Native daemon connection URL when it differs from the managed bind endpoint. */ + url?: string; + /** HTTP host for the managed signal-cli daemon (default 127.0.0.1). */ + httpHost?: string; + /** HTTP port for the managed signal-cli daemon (default 8080). */ + httpPort?: number; + /** signal-cli binary path (default: signal-cli). */ + cliPath?: string; + /** Max time to wait for signal-cli daemon startup (ms, cap 120000). */ + startupTimeoutMs?: number; + receiveMode?: "on-start" | "manual"; + ignoreStories?: boolean; + } + | { + kind: "external-native"; + /** Base URL for an externally managed native signal-cli HTTP daemon. */ + url: string; + } + | { + kind: "container"; + /** Base URL for bbernhard/signal-cli-rest-api. */ + url: string; + }; export type SignalGroupConfig = { requireMention?: boolean; @@ -26,19 +53,10 @@ export type SignalAccountConfig = Omit; /** Per-group overrides keyed by Signal group id (or "*"). */ @@ -53,14 +71,6 @@ export type SignalAccountConfig = Omit; /** Optional default account id when multiple accounts are configured. */ diff --git a/src/plugin-sdk/channel-setup.ts b/src/plugin-sdk/channel-setup.ts index 15aac315a2c2..6ee5b187b1f4 100644 --- a/src/plugin-sdk/channel-setup.ts +++ b/src/plugin-sdk/channel-setup.ts @@ -1,13 +1,14 @@ // Channel setup contracts expose setup wizard hooks and account config writes to plugins. import type { ChannelSetupWizard } from "../channels/plugins/setup-wizard-types.js"; import type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; +export { defineChannelSetupContract } from "../channels/plugins/setup-contract.js"; import { createOptionalChannelSetupAdapter, createOptionalChannelSetupWizard, } from "./optional-channel-setup.js"; export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; -export type { ChannelSetupInput } from "../channels/plugins/types.core.js"; +export type { ChannelSetupInput } from "../channels/plugins/setup-input.js"; export type { ChannelSetupDmPolicy, ChannelSetupWizard } from "./setup.js"; export { DEFAULT_ACCOUNT_ID, diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index f588cbeded07..b38e065f6b74 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -526,18 +526,21 @@ type CreateChannelPluginBaseOptions = { configSchema?: ChannelPlugin["configSchema"]; config?: ChannelPlugin["config"]; security?: ChannelPlugin["security"]; - setup: NonNullable["setup"]>; + setup?: NonNullable["setup"]>; + setupContract?: NonNullable["setupContract"]>; groups?: ChannelPlugin["groups"]; }; type CreatedChannelPluginBase = Pick< ChannelPlugin, - "id" | "meta" | "setup" + "id" | "meta" > & Partial< Pick< ChannelPlugin, | "setupWizard" + | "setup" + | "setupContract" | "capabilities" | "commands" | "doctor" @@ -868,7 +871,8 @@ export function createChannelPluginBase( ...(params.config ? { config: params.config } : {}), ...(params.security ? { security: params.security } : {}), ...(params.groups ? { groups: params.groups } : {}), - setup: params.setup, + ...(params.setup ? { setup: params.setup } : {}), + ...(params.setupContract ? { setupContract: params.setupContract } : {}), } as CreatedChannelPluginBase; } /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/plugins/manifest-registry-installed.test.ts b/src/plugins/manifest-registry-installed.test.ts index 77b7e7fc6b80..257ef704274a 100644 --- a/src/plugins/manifest-registry-installed.test.ts +++ b/src/plugins/manifest-registry-installed.test.ts @@ -493,7 +493,7 @@ describe("loadPluginManifestRegistryForInstalledIndex", () => { expect(registry.plugins[0]?.skills).toEqual(["commands"]); }); - it("hydrates package channel command metadata while reconstructing from an older index", () => { + it("hydrates package metadata while dropping setup fields with mismatched CLI names", () => { const rootDir = makeTempDir(); writePlugin(rootDir, "installed", "installed-"); fs.writeFileSync( @@ -507,6 +507,46 @@ describe("loadPluginManifestRegistryForInstalledIndex", () => { nativeCommandsAutoEnabled: true, nativeSkillsAutoEnabled: false, }, + setup: { + fields: [ + { + key: "tenant", + kind: "choice", + choices: ["alpha", "beta"], + cli: { + flags: "--tenant ", + negatedFlags: "--no-tenant", + description: "Installed tenant", + }, + }, + { + key: "credential", + kind: "string", + cli: { + flags: "--token ", + description: "Installed credential", + }, + }, + { + key: "tenant", + kind: "boolean", + cli: { + flags: "--tenant", + negatedFlags: "--no-other", + description: "Mismatched tenant toggle", + }, + }, + { + key: "useEnv", + kind: "boolean", + cli: { + flags: "--use-env", + negatedFlags: "--no-use-env", + description: "Use environment credentials", + }, + }, + ], + }, }, }, }), @@ -542,6 +582,29 @@ describe("loadPluginManifestRegistryForInstalledIndex", () => { nativeCommandsAutoEnabled: true, nativeSkillsAutoEnabled: false, }); + expect(registry.plugins[0]?.packageManifest?.channel?.setup).toEqual({ + fields: [ + { + key: "tenant", + kind: "choice", + choices: ["alpha", "beta"], + cli: { + flags: "--tenant ", + negatedFlags: "--no-tenant", + description: "Installed tenant", + }, + }, + { + key: "useEnv", + kind: "boolean", + cli: { + flags: "--use-env", + negatedFlags: "--no-use-env", + description: "Use environment credentials", + }, + }, + ], + }); }); it("hydrates package metadata from dot-prefixed package directories", () => { diff --git a/src/plugins/manifest-registry-installed.ts b/src/plugins/manifest-registry-installed.ts index 832abc49e21f..5afdb6c3c23e 100644 --- a/src/plugins/manifest-registry-installed.ts +++ b/src/plugins/manifest-registry-installed.ts @@ -4,6 +4,10 @@ import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { normalizeOptionalTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; +import { + resolveChannelSetupFieldCliAttributeName, + type ChannelSetupFieldMetadata, +} from "../channels/plugins/setup-contract.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { tryReadJsonSync } from "../infra/json-files.js"; import type { PluginCandidate } from "./discovery.js"; @@ -434,6 +438,75 @@ function normalizePackageChannelCliOptions( return normalized.length > 0 ? normalized : undefined; } +function normalizePackageChannelSetup(setup: unknown): PluginPackageChannel["setup"] | undefined { + if (!isRecord(setup) || !Array.isArray(setup.fields)) { + return undefined; + } + const fields: ChannelSetupFieldMetadata[] = []; + for (const value of setup.fields) { + if (!isRecord(value) || !isRecord(value.cli)) { + continue; + } + const key = normalizeOptionalString(value.key); + const kind = normalizeOptionalString(value.kind); + const flags = normalizeOptionalString(value.cli.flags); + const negatedFlags = normalizeOptionalString(value.cli.negatedFlags); + const description = normalizeOptionalString(value.cli.description); + if ( + !key || + !flags || + !description || + !kind || + (kind !== "string" && + kind !== "boolean" && + kind !== "integer" && + kind !== "string-list" && + kind !== "choice") + ) { + continue; + } + try { + if ( + resolveChannelSetupFieldCliAttributeName(flags) !== key || + (negatedFlags && resolveChannelSetupFieldCliAttributeName(negatedFlags) !== key) + ) { + continue; + } + } catch { + continue; + } + const defaultValue = + typeof value.cli.defaultValue === "boolean" || typeof value.cli.defaultValue === "string" + ? value.cli.defaultValue + : undefined; + const cli = { + flags, + ...(negatedFlags ? { negatedFlags } : {}), + description, + ...(defaultValue !== undefined ? { defaultValue } : {}), + }; + if (kind === "choice") { + const choices = normalizeOptionalTrimmedStringList(value.choices); + if (!choices?.length) { + continue; + } + fields.push({ key, kind, choices, cli }); + continue; + } + if (kind === "string" || kind === "string-list") { + fields.push({ + key, + kind, + ...(value.sensitive === true ? { sensitive: true } : {}), + cli, + }); + continue; + } + fields.push({ key, kind, cli }); + } + return { fields }; +} + function normalizePersistedPackageChannel(value: unknown): PluginPackageChannel | undefined { if (!isRecord(value)) { return undefined; @@ -498,6 +571,10 @@ function normalizePersistedPackageChannel(value: unknown): PluginPackageChannel if (doctorCapabilities) { channel.doctorCapabilities = doctorCapabilities; } + const setup = normalizePackageChannelSetup(value.setup); + if (setup) { + channel.setup = setup; + } const cliAddOptions = normalizePackageChannelCliOptions(value.cliAddOptions); if (cliAddOptions) { channel.cliAddOptions = cliAddOptions; diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 39aa09c90929..9730a8eb1cce 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -2078,6 +2078,8 @@ export function loadPluginManifest( } // package.json "openclaw" metadata (used for setup/catalog) +import type { ChannelSetupMetadata } from "../channels/plugins/setup-contract.js"; + type PluginPackageChannelApprovalFlag = "native"; export type PluginPackageChannel = { @@ -2120,6 +2122,9 @@ export type PluginPackageChannel = { exportName?: string; }; doctorCapabilities?: PluginPackageChannelDoctorCapabilities; + /** Typed, serializable setup fields available before plugin runtime load. */ + setup?: ChannelSetupMetadata; + /** @deprecated Use setup.fields. */ cliAddOptions?: readonly PluginPackageChannelCliOption[]; }; @@ -2132,6 +2137,7 @@ export type PluginPackageChannelDoctorCapabilities = { export type PluginPackageChannelCliOption = { flags: string; + negatedFlags?: string; description: string; defaultValue?: boolean | string; valueType?: "int" | "list";