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 <steipete@gmail.com>
This commit is contained in:
Jesse Merhi
2026-07-23 09:57:42 +10:00
committed by GitHub
parent b375776acf
commit 4a2a600809
181 changed files with 9189 additions and 1721 deletions

View File

@@ -1,5 +1,5 @@
{
"core": 2303,
"channel": 3627,
"channel": 3632,
"plugin": 3556
}

View File

@@ -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

View File

@@ -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

View File

@@ -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:<id>` 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:<id>` 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:<id>`). `open` requires `"*"`. Signal has no usernames; use phone/UUID IDs.

View File

@@ -80,13 +80,17 @@ openclaw channels remove --channel telegram --delete
```
<Tip>
`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.
</Tip>
`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 <id>`, `--name <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 <id>`, 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 <id>`, `--name <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 <id>` both preselect that channel without bypassing guidance:
```bash

View File

@@ -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

View File

@@ -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 <channel-id> --help` and `openclaw channels add --channel <channel-id> --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 <url>", description: "Service endpoint" },
},
transport: {
kind: "choice",
choices: ["native", "container"],
cli: { flags: "--transport <kind>", 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 <url>", "description": "Service endpoint" }
},
{
"key": "transport",
"kind": "choice",
"choices": ["native", "container"],
"cli": { "flags": "--transport <kind>", "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:

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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/<version>/`.
- 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

View File

@@ -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/<version>/`
- 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

View File

@@ -52,20 +52,19 @@
"nativeCommandsAutoEnabled": false,
"nativeSkillsAutoEnabled": false
},
"cliAddOptions": [
{
"flags": "--base-url <url>",
"description": "Channel base URL"
},
{
"flags": "--code <code>",
"description": "ClickClack one-time setup code or setup URL"
},
{
"flags": "--workspace <workspace>",
"description": "ClickClack workspace id, slug, or name"
}
]
"setup": {
"fields": [
{ "key": "code", "kind": "string", "sensitive": true, "cli": { "flags": "--code <code>", "description": "ClickClack one-time setup code or setup URL" } },
{ "key": "token", "kind": "string", "sensitive": true, "cli": { "flags": "--token <token>", "description": "ClickClack bot token" } },
{ "key": "tokenFile", "kind": "string", "sensitive": true, "cli": { "flags": "--token-file <path>", "description": "ClickClack bot token file" } },
{ "key": "baseUrl", "kind": "string", "cli": { "flags": "--base-url <url>", "description": "ClickClack API base URL" } },
{ "key": "workspace", "kind": "string", "cli": { "flags": "--workspace <workspace>", "description": "ClickClack workspace id, slug, or name" } },
{ "key": "defaultTo", "kind": "string", "cli": { "flags": "--default-to <target>", "description": "Default ClickClack target" } },
{ "key": "allowFrom", "kind": "string-list", "cli": { "flags": "--allow-from <ids>", "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",

View File

@@ -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<ResolvedClickClackAccount> = {
configSchema: clickClackConfigSchema,
config: clickClackConfigAdapter,
setup: clickClackSetupAdapter,
setupContract: clickClackSetupContract,
setupWizard: clickClackSetupWizard,
};

View File

@@ -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<ResolvedClickClackAccount> = create
configSchema: clickClackConfigSchema,
config: clickClackConfigAdapter,
setup: clickClackSetupAdapter,
setupContract: clickClackSetupContract,
setupWizard: clickClackSetupWizard,
secrets: {
secretTargetRegistryEntries,

View File

@@ -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 <code>", description: "ClickClack one-time setup code or setup URL" },
},
token: {
kind: "string",
sensitive: true,
cli: { flags: "--token <token>", description: "ClickClack bot token" },
},
tokenFile: {
kind: "string",
sensitive: true,
cli: { flags: "--token-file <path>", description: "ClickClack bot token file" },
},
baseUrl: {
kind: "string",
cli: { flags: "--base-url <url>", description: "ClickClack API base URL" },
},
workspace: {
kind: "string",
cli: {
flags: "--workspace <workspace>",
description: "ClickClack workspace id, slug, or name",
},
},
defaultTo: {
kind: "string",
cli: { flags: "--default-to <target>", description: "Default ClickClack target" },
},
allowFrom: {
kind: "string-list",
cli: { flags: "--allow-from <ids>", 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,
});

View File

@@ -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 <token>", "description": "Discord bot token" }
},
{
"key": "useEnv",
"kind": "boolean",
"cli": { "flags": "--use-env", "description": "Use DISCORD_BOT_TOKEN" }
}
]
},
"commands": {
"nativeCommandsAutoEnabled": true,
"nativeSkillsAutoEnabled": true

View File

@@ -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<ResolvedDiscordAccount> = {
...createDiscordPluginBase({
setupWizard: discordSetupWizard,
setup: discordSetupAdapter,
setupContract: discordSetupContract,
}),
};

View File

@@ -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<ResolvedDiscordAccount, DiscordProbe>
base: {
...createDiscordPluginBase({
setup: discordSetupAdapter,
setupContract: discordSetupContract,
}),
allowlist: {
...buildLegacyDmAccountAllowlistAdapter({

View File

@@ -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 <token>", description: "Discord bot token" },
},
useEnv: {
kind: "boolean",
cli: { flags: "--use-env", description: "Use DISCORD_BOT_TOKEN" },
},
},
legacyAdapter: discordSetupAdapter,
});

View File

@@ -95,6 +95,7 @@ export const discordConfigAdapter = createScopedChannelConfigAdapter<
export function createDiscordPluginBase(params: {
setup: NonNullable<ChannelPlugin<ResolvedDiscordAccount>["setup"]>;
setupContract?: NonNullable<ChannelPlugin<ResolvedDiscordAccount>["setupContract"]>;
setupWizard?: ChannelPlugin<ResolvedDiscordAccount>["setupWizard"];
}): Pick<
ChannelPlugin<ResolvedDiscordAccount>,
@@ -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: {

View File

@@ -53,7 +53,8 @@
"lark"
],
"order": 35,
"quickstartAllowFrom": true
"quickstartAllowFrom": true,
"setup": { "fields": [] }
},
"install": {
"npmSpec": "@openclaw/feishu",

View File

@@ -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<ResolvedFeishuAccount, FeishuProbeResul
},
},
setup: feishuSetupAdapter,
setupContract: feishuSetupContract,
setupWizard: feishuSetupWizard,
messaging: {
targetPrefixes: ["feishu", "lark"],

View File

@@ -1,3 +1,4 @@
import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup";
// Feishu plugin module implements setup core behavior.
import {
DEFAULT_ACCOUNT_ID,
@@ -50,3 +51,8 @@ export const feishuSetupAdapter: ChannelSetupAdapter = {
return setFeishuNamedAccountEnabled(cfg, accountId, true);
},
};
export const feishuSetupContract = defineChannelSetupContract({
fields: {},
legacyAdapter: feishuSetupAdapter,
});

View File

@@ -58,24 +58,48 @@
"groupAllowFromFallbackToAllowFrom": false,
"warnOnEmptyGroupSenderAllowlist": false
},
"cliAddOptions": [
{
"flags": "--webhook-path <path>",
"description": "Google Chat webhook path"
},
{
"flags": "--webhook-url <url>",
"description": "Google Chat webhook URL"
},
{
"flags": "--audience-type <type>",
"description": "Google Chat audience type (app-url|project-number)"
},
{
"flags": "--audience <value>",
"description": "Google Chat audience value (app URL or project number)"
}
]
"setup": {
"fields": [
{
"key": "token",
"kind": "string",
"sensitive": true,
"cli": { "flags": "--token <json>", "description": "Google Chat service account JSON" }
},
{
"key": "tokenFile",
"kind": "string",
"sensitive": true,
"cli": { "flags": "--token-file <path>", "description": "Google Chat service account file" }
},
{
"key": "audienceType",
"kind": "choice",
"choices": ["app-url", "project-number"],
"cli": { "flags": "--audience-type <type>", "description": "Google Chat audience type" }
},
{
"key": "audience",
"kind": "string",
"cli": { "flags": "--audience <value>", "description": "Google Chat audience value" }
},
{
"key": "webhookPath",
"kind": "string",
"cli": { "flags": "--webhook-path <path>", "description": "Google Chat webhook path" }
},
{
"key": "webhookUrl",
"kind": "string",
"cli": { "flags": "--webhook-url <url>", "description": "Google Chat webhook URL" }
},
{
"key": "useEnv",
"kind": "boolean",
"cli": { "flags": "--use-env", "description": "Use Google Chat environment credentials" }
}
]
}
},
"install": {
"npmSpec": "@openclaw/googlechat",

View File

@@ -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"],

View File

@@ -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 <json>", description: "Google Chat service account JSON" },
},
tokenFile: {
kind: "string",
sensitive: true,
cli: { flags: "--token-file <path>", description: "Google Chat service account file" },
},
audienceType: {
kind: "choice",
choices: ["app-url", "project-number"],
cli: { flags: "--audience-type <type>", description: "Google Chat audience type" },
},
audience: {
kind: "string",
cli: { flags: "--audience <value>", description: "Google Chat audience value" },
},
webhookPath: {
kind: "string",
cli: { flags: "--webhook-path <path>", description: "Google Chat webhook path" },
},
webhookUrl: {
kind: "string",
cli: { flags: "--webhook-url <url>", description: "Google Chat webhook URL" },
},
useEnv: {
kind: "boolean",
cli: { flags: "--use-env", description: "Use Google Chat environment credentials" },
},
},
legacyAdapter: googlechatSetupAdapter,
});

View File

@@ -32,24 +32,31 @@
"imsg"
],
"systemImage": "message.fill",
"cliAddOptions": [
{
"flags": "--cli-path <path>",
"description": "Channel CLI path"
},
{
"flags": "--db-path <path>",
"description": "iMessage database path"
},
{
"flags": "--service <service>",
"description": "iMessage service (imessage|sms|auto)"
},
{
"flags": "--region <region>",
"description": "iMessage region (for SMS)"
}
]
"setup": {
"fields": [
{
"key": "cliPath",
"kind": "string",
"cli": { "flags": "--cli-path <path>", "description": "iMessage CLI path" }
},
{
"key": "dbPath",
"kind": "string",
"cli": { "flags": "--db-path <path>", "description": "iMessage database path" }
},
{
"key": "service",
"kind": "choice",
"choices": ["imessage", "sms", "auto"],
"cli": { "flags": "--service <service>", "description": "iMessage service" }
},
{
"key": "region",
"kind": "string",
"cli": { "flags": "--region <region>", "description": "SMS region" }
}
]
}
},
"compat": {
"pluginApi": ">=2026.7.2"

View File

@@ -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<ResolvedIMessageAccount> = {
...createIMessagePluginBase({
setupWizard: imessageSetupWizard,
setup: imessageSetupAdapter,
setupContract: imessageSetupContract,
}),
};

View File

@@ -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<ResolvedIMessageAccount, IMessageProb
...createIMessagePluginBase({
setupWizard: imessageSetupWizard,
setup: imessageSetupAdapter,
setupContract: imessageSetupContract,
}),
allowlist: buildDmGroupAccountAllowlistAdapter({
channelId: "imessage",

View File

@@ -1,4 +1,7 @@
import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup";
import {
defineChannelSetupContract,
type ChannelSetupInput,
} from "openclaw/plugin-sdk/channel-setup";
// Imessage plugin module implements setup core behavior.
import type {
ChannelSetupAdapter,
@@ -235,6 +238,29 @@ export const imessageSetupAdapter: ChannelSetupAdapter = {
singleAccountKeysToMove: ["cliPath", "dbPath", "service", "region"],
};
export const imessageSetupContract = defineChannelSetupContract({
fields: {
cliPath: {
kind: "string",
cli: { flags: "--cli-path <path>", description: "iMessage CLI path" },
},
dbPath: {
kind: "string",
cli: { flags: "--db-path <path>", description: "iMessage database path" },
},
service: {
kind: "choice",
choices: ["imessage", "sms", "auto"],
cli: { flags: "--service <service>", description: "iMessage service" },
},
region: {
kind: "string",
cli: { flags: "--region <region>", description: "SMS region" },
},
},
legacyAdapter: imessageSetupAdapter,
});
export const imessageSetupStatusBase = {
configuredLabel: t("wizard.channels.statusConfigured"),
unconfiguredLabel: t("wizard.channels.statusNeedsSetup"),

View File

@@ -59,6 +59,7 @@ export const imessageSecurityAdapter =
export function createIMessagePluginBase(params: {
setupWizard?: NonNullable<ChannelPlugin<ResolvedIMessageAccount>["setupWizard"]>;
setup: NonNullable<ChannelPlugin<ResolvedIMessageAccount>["setup"]>;
setupContract?: NonNullable<ChannelPlugin<ResolvedIMessageAccount>["setupContract"]>;
}): Pick<
ChannelPlugin<ResolvedIMessageAccount>,
| "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,

View File

@@ -41,12 +41,17 @@
"internet-relay-chat"
],
"systemImage": "network",
"cliAddOptions": [
{
"flags": "--password <password>",
"description": "Channel password or login secret"
}
]
"setup": { "fields": [
{ "key": "host", "kind": "string", "cli": { "flags": "--host <host>", "description": "IRC server host" } },
{ "key": "port", "kind": "string", "cli": { "flags": "--port <port>", "description": "IRC server port" } },
{ "key": "tls", "kind": "boolean", "cli": { "flags": "--tls", "description": "Use TLS for IRC" } },
{ "key": "nick", "kind": "string", "cli": { "flags": "--nick <nick>", "description": "IRC nickname" } },
{ "key": "username", "kind": "string", "cli": { "flags": "--username <name>", "description": "IRC username" } },
{ "key": "realname", "kind": "string", "cli": { "flags": "--realname <name>", "description": "IRC real name" } },
{ "key": "channels", "kind": "string-list", "cli": { "flags": "--channels <names>", "description": "IRC channels" } },
{ "key": "password", "kind": "string", "sensitive": true, "cli": { "flags": "--password <password>", "description": "IRC server password" } },
{ "key": "useEnv", "kind": "boolean", "cli": { "flags": "--use-env", "description": "Use IRC environment configuration" } }
] }
},
"compat": {
"pluginApi": ">=2026.7.2"

View File

@@ -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<ResolvedIrcAccount, IrcProbe> = createChat
quickstartAllowFrom: true,
},
setup: ircSetupAdapter,
setupContract: ircSetupContract,
setupWizard: ircSetupWizard,
capabilities: {
chatTypes: ["direct", "group"],

View File

@@ -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 <host>", description: "IRC server host" } },
port: { kind: "string", cli: { flags: "--port <port>", description: "IRC server port" } },
tls: { kind: "boolean", cli: { flags: "--tls", description: "Use TLS for IRC" } },
nick: { kind: "string", cli: { flags: "--nick <nick>", description: "IRC nickname" } },
username: { kind: "string", cli: { flags: "--username <name>", description: "IRC username" } },
realname: { kind: "string", cli: { flags: "--realname <name>", description: "IRC real name" } },
channels: {
kind: "string-list",
cli: { flags: "--channels <names>", description: "IRC channels" },
},
password: {
kind: "string",
sensitive: true,
cli: { flags: "--password <password>", description: "IRC server password" },
},
useEnv: {
kind: "boolean",
cli: { flags: "--use-env", description: "Use IRC environment configuration" },
},
},
legacyAdapter: ircSetupAdapter,
});

View File

@@ -47,12 +47,14 @@
"systemImage": "message",
"order": 75,
"quickstartAllowFrom": true,
"cliAddOptions": [
{
"flags": "--secret-file <path>",
"description": "Read channel shared secret from file"
}
]
"setup": { "fields": [
{ "key": "channelAccessToken", "kind": "string", "sensitive": true, "cli": { "flags": "--channel-access-token <token>", "description": "LINE channel access token" } },
{ "key": "token", "kind": "string", "sensitive": true, "cli": { "flags": "--token <token>", "description": "LINE channel access token (alias)" } },
{ "key": "channelSecret", "kind": "string", "sensitive": true, "cli": { "flags": "--channel-secret <secret>", "description": "LINE channel secret" } },
{ "key": "tokenFile", "kind": "string", "sensitive": true, "cli": { "flags": "--token-file <path>", "description": "LINE access token file" } },
{ "key": "secretFile", "kind": "string", "sensitive": true, "cli": { "flags": "--secret-file <path>", "description": "LINE channel secret file" } },
{ "key": "useEnv", "kind": "boolean", "cli": { "flags": "--use-env", "description": "Use LINE environment credentials" } }
] }
},
"install": {
"npmSpec": "@openclaw/line",

View File

@@ -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<ResolvedLineAccount> = {
@@ -9,4 +9,5 @@ export const lineSetupPlugin: ChannelPlugin<ResolvedLineAccount> = {
...lineChannelPluginCommon,
setupWizard: lineSetupWizard,
setup: lineSetupAdapter,
setupContract: lineSetupContract,
};

View File

@@ -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<ResolvedLineAccount> = createChatChannelP
},
directory: createEmptyChannelDirectoryAdapter(),
setup: lineSetupAdapter,
setupContract: lineSetupContract,
status: lineStatusAdapter,
gateway: lineGatewayAdapter,
message: lineMessageAdapter,

View File

@@ -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<string, unknown>): 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");
});
});

View File

@@ -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 <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 <token>", description: "LINE channel access token (alias)" },
},
channelSecret: {
kind: "string",
sensitive: true,
cli: { flags: "--channel-secret <secret>", description: "LINE channel secret" },
},
tokenFile: {
kind: "string",
sensitive: true,
cli: { flags: "--token-file <path>", description: "LINE access token file" },
},
secretFile: {
kind: "string",
sensitive: true,
cli: { flags: "--secret-file <path>", description: "LINE channel secret file" },
},
useEnv: {
kind: "boolean",
cli: { flags: "--use-env", description: "Use LINE environment credentials" },
},
},
legacyAdapter: lineSetupAdapter,
});
export { listLineAccountIds };

View File

@@ -70,33 +70,20 @@
"groupAllowFromFallbackToAllowFrom": false,
"warnOnEmptyGroupSenderAllowlist": true
},
"cliAddOptions": [
{
"flags": "--homeserver <url>",
"description": "Matrix homeserver URL"
},
{
"flags": "--user-id <id>",
"description": "Matrix user ID"
},
{
"flags": "--access-token <token>",
"description": "Matrix access token"
},
{
"flags": "--password <password>",
"description": "Channel password or login secret"
},
{
"flags": "--device-name <name>",
"description": "Matrix device name"
},
{
"flags": "--initial-sync-limit <n>",
"description": "Matrix initial sync limit",
"valueType": "int"
}
],
"setup": {
"fields": [
{ "key": "homeserver", "kind": "string", "cli": { "flags": "--homeserver <url>", "description": "Matrix homeserver URL" } },
{ "key": "userId", "kind": "string", "cli": { "flags": "--user-id <id>", "description": "Matrix user id" } },
{ "key": "accessToken", "kind": "string", "sensitive": true, "cli": { "flags": "--access-token <token>", "description": "Matrix access token" } },
{ "key": "password", "kind": "string", "sensitive": true, "cli": { "flags": "--password <password>", "description": "Matrix password" } },
{ "key": "deviceName", "kind": "string", "cli": { "flags": "--device-name <name>", "description": "Matrix device name" } },
{ "key": "avatarUrl", "kind": "string", "cli": { "flags": "--avatar-url <url>", "description": "Matrix avatar URL" } },
{ "key": "initialSyncLimit", "kind": "integer", "cli": { "flags": "--initial-sync-limit <n>", "description": "Matrix initial sync room limit" } },
{ "key": "proxy", "kind": "string", "cli": { "flags": "--proxy <url>", "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"

View File

@@ -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<ResolvedMatrixAccount> = {
},
setupWizard: matrixSetupWizard,
setup: matrixSetupAdapter,
setupContract: matrixSetupContract,
capabilities: {
chatTypes: ["direct", "group", "thread"],
polls: true,

View File

@@ -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<ResolvedMatrixAccount, MatrixProbe> =
namedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,
},
setupContract: {
...matrixSetupContract,
singleAccountKeysToMove,
namedAccountPromotionKeys,
resolveSingleAccountPromotionTarget,
},
bindings: {
compileConfiguredBinding: ({ conversationId }) =>
normalizeMatrixAcpConversationId(conversationId),

View File

@@ -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 <url>", description: "Matrix homeserver URL" },
},
userId: { kind: "string", cli: { flags: "--user-id <id>", description: "Matrix user id" } },
accessToken: {
kind: "string",
sensitive: true,
cli: { flags: "--access-token <token>", description: "Matrix access token" },
},
password: {
kind: "string",
sensitive: true,
cli: { flags: "--password <password>", description: "Matrix password" },
},
deviceName: {
kind: "string",
cli: { flags: "--device-name <name>", description: "Matrix device name" },
},
avatarUrl: {
kind: "string",
cli: { flags: "--avatar-url <url>", description: "Matrix avatar URL" },
},
initialSyncLimit: {
kind: "integer",
cli: { flags: "--initial-sync-limit <n>", description: "Matrix initial sync room limit" },
},
proxy: { kind: "string", cli: { flags: "--proxy <url>", 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,
});

View File

@@ -44,16 +44,12 @@
"docsLabel": "mattermost",
"blurb": "self-hosted Slack-style chat; install the plugin to enable.",
"order": 65,
"cliAddOptions": [
{
"flags": "--bot-token <token>",
"description": "Bot token"
},
{
"flags": "--http-url <url>",
"description": "Channel HTTP service URL"
}
]
"setup": { "fields": [
{ "key": "token", "kind": "string", "sensitive": true, "cli": { "flags": "--token <token>", "description": "Mattermost bot token" } },
{ "key": "botToken", "kind": "string", "sensitive": true, "cli": { "flags": "--bot-token <token>", "description": "Mattermost bot token" } },
{ "key": "httpUrl", "kind": "string", "cli": { "flags": "--http-url <url>", "description": "Mattermost server URL" } },
{ "key": "useEnv", "kind": "boolean", "cli": { "flags": "--use-env", "description": "Use Mattermost environment credentials" } }
] }
},
"install": {
"clawhubSpec": "clawhub:@openclaw/mattermost",

View File

@@ -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<ResolvedMattermostAccount> = {
@@ -42,5 +42,6 @@ export const mattermostSetupPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
resolveGatewayAuthBypassPaths: resolveMattermostGatewayAuthBypassPaths,
},
setup: mattermostSetupAdapter,
setupContract: mattermostSetupContract,
setupWizard: mattermostSetupWizard,
};

View File

@@ -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<ResolvedMattermostAccount> = create
...meta,
},
setup: mattermostSetupAdapter,
setupContract: mattermostSetupContract,
setupWizard: mattermostSetupWizard,
capabilities: {
chatTypes: ["direct", "channel", "group", "thread"],

View File

@@ -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 <token>", description: "Mattermost bot token" },
},
botToken: {
kind: "string",
sensitive: true,
cli: { flags: "--bot-token <token>", description: "Mattermost bot token" },
},
httpUrl: {
kind: "string",
cli: { flags: "--http-url <url>", description: "Mattermost server URL" },
},
useEnv: {
kind: "boolean",
cli: { flags: "--use-env", description: "Use Mattermost environment credentials" },
},
},
legacyAdapter: mattermostSetupAdapter,
});

View File

@@ -59,7 +59,8 @@
"groupModel": "hybrid",
"groupAllowFromFallbackToAllowFrom": true,
"warnOnEmptyGroupSenderAllowlist": true
}
},
"setup": { "fields": [] }
},
"install": {
"npmSpec": "@openclaw/msteams",

View File

@@ -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<ResolvedMSTeamsAccount> = {
},
setupWizard: msteamsSetupWizard,
setup: msteamsSetupAdapter,
setupContract: msteamsSetupContract,
};

View File

@@ -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<ResolvedMSTeamsAccount, ProbeMSTeamsRe
collectMutableAllowlistWarnings: collectMSTeamsMutableAllowlistWarnings,
},
setup: msteamsSetupAdapter,
setupContract: msteamsSetupContract,
messaging: {
targetPrefixes: ["msteams", "teams"],
directTargetStyle: "user-prefixed",

View File

@@ -1,3 +1,4 @@
import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup";
// Msteams plugin module implements setup core behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
@@ -28,6 +29,11 @@ export const msteamsSetupAdapter: ChannelSetupAdapter = {
}),
};
export const msteamsSetupContract = defineChannelSetupContract({
fields: {},
legacyAdapter: msteamsSetupAdapter,
});
const channel = "msteams" as const;
async function promptMSTeamsCredentials(prompter: WizardPrompter): Promise<{

View File

@@ -48,28 +48,16 @@
],
"order": 65,
"quickstartAllowFrom": true,
"cliAddOptions": [
{
"flags": "--base-url <url>",
"description": "Channel base URL"
},
{
"flags": "--secret <secret>",
"description": "Channel shared secret"
},
{
"flags": "--secret-file <path>",
"description": "Read channel shared secret from file"
},
{
"flags": "--password <password>",
"description": "Channel password or login secret"
},
{
"flags": "--url <url>",
"description": "Channel setup URL"
}
]
"setup": { "fields": [
{ "key": "baseUrl", "kind": "string", "cli": { "flags": "--base-url <url>", "description": "Nextcloud base URL" } },
{ "key": "url", "kind": "string", "cli": { "flags": "--url <url>", "description": "Legacy Nextcloud base URL alias" } },
{ "key": "secret", "kind": "string", "sensitive": true, "cli": { "flags": "--secret <secret>", "description": "Nextcloud Talk bot secret" } },
{ "key": "token", "kind": "string", "sensitive": true, "cli": { "flags": "--token <secret>", "description": "Legacy Nextcloud bot secret alias" } },
{ "key": "password", "kind": "string", "sensitive": true, "cli": { "flags": "--password <secret>", "description": "Legacy Nextcloud bot secret alias" } },
{ "key": "secretFile", "kind": "string", "sensitive": true, "cli": { "flags": "--secret-file <path>", "description": "Nextcloud Talk bot secret file" } },
{ "key": "tokenFile", "kind": "string", "sensitive": true, "cli": { "flags": "--token-file <path>", "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",

View File

@@ -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<ResolvedNextcloudTalkAccount> =
collectRuntimeConfigAssignments,
},
setup: nextcloudTalkSetupAdapter,
setupContract: nextcloudTalkSetupContract,
status: createComputedAccountStatusAdapter<ResolvedNextcloudTalkAccount>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
buildChannelSummary: ({ snapshot }) =>

View File

@@ -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 <url>", description: "Nextcloud base URL" },
},
url: {
kind: "string",
cli: { flags: "--url <url>", description: "Legacy Nextcloud base URL alias" },
},
secret: {
kind: "string",
sensitive: true,
cli: { flags: "--secret <secret>", description: "Nextcloud Talk bot secret" },
},
token: {
kind: "string",
sensitive: true,
cli: { flags: "--token <secret>", description: "Legacy Nextcloud bot secret alias" },
},
password: {
kind: "string",
sensitive: true,
cli: { flags: "--password <secret>", description: "Legacy Nextcloud bot secret alias" },
},
secretFile: {
kind: "string",
sensitive: true,
cli: { flags: "--secret-file <path>", description: "Nextcloud Talk bot secret file" },
},
tokenFile: {
kind: "string",
sensitive: true,
cli: { flags: "--token-file <path>", description: "Legacy Nextcloud bot secret file alias" },
},
useEnv: {
kind: "boolean",
cli: { flags: "--use-env", description: "Use Nextcloud Talk environment credentials" },
},
},
legacyAdapter: nextcloudTalkSetupAdapter,
});

View File

@@ -44,16 +44,13 @@
"blurb": "Decentralized protocol; encrypted DMs via NIP-04.",
"order": 55,
"quickstartAllowFrom": true,
"cliAddOptions": [
{
"flags": "--private-key <key>",
"description": "Nostr private key (nsec... or hex)"
},
{
"flags": "--relay-urls <list>",
"description": "Nostr relay URLs (comma-separated)"
}
]
"setup": {
"fields": [
{ "key": "privateKey", "kind": "string", "sensitive": true, "cli": { "flags": "--private-key <key>", "description": "Nostr private key" } },
{ "key": "relayUrls", "kind": "string", "cli": { "flags": "--relay-urls <urls>", "description": "Nostr relay URLs" } },
{ "key": "useEnv", "kind": "boolean", "cli": { "flags": "--use-env", "description": "Use NOSTR_PRIVATE_KEY" } }
]
}
},
"install": {
"npmSpec": "@openclaw/nostr",

View File

@@ -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<ResolvedNostrSetupAccount> = {
reload: { configPrefixes: ["channels.nostr"] },
configSchema: buildChannelConfigSchema(NostrConfigSchema),
setup: nostrSetupAdapter,
setupContract: nostrSetupContract,
setupWizard: nostrSetupWizard,
config: {
listAccountIds: listSetupNostrAccountIds,

View File

@@ -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<ResolvedNostrAccount> = createChatChanne
reload: { configPrefixes: ["channels.nostr"] },
configSchema: buildChannelConfigSchema(NostrConfigSchema),
setup: nostrSetupAdapter,
setupContract: nostrSetupContract,
setupWizard: nostrSetupWizard,
config: {
...nostrConfigAdapter,

View File

@@ -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 <key>", description: "Nostr private key" },
},
relayUrls: {
kind: "string",
cli: { flags: "--relay-urls <urls>", description: "Nostr relay URLs" },
},
useEnv: {
kind: "boolean",
cli: { flags: "--use-env", description: "Use NOSTR_PRIVATE_KEY" },
},
},
legacyAdapter: adapter,
});
}

View File

@@ -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,

View File

@@ -46,12 +46,11 @@
"setup": false,
"docs": false
},
"cliAddOptions": [
{
"flags": "--base-url <url>",
"description": "Channel base URL"
}
]
"setup": { "fields": [
{ "key": "baseUrl", "kind": "string", "cli": { "flags": "--base-url <url>", "description": "QA channel base URL" } },
{ "key": "botUserId", "kind": "string", "cli": { "flags": "--bot-user-id <id>", "description": "QA channel bot user id" } },
{ "key": "botDisplayName", "kind": "string", "cli": { "flags": "--bot-display-name <name>", "description": "QA channel bot display name" } }
] }
}
}
}

View File

@@ -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<ResolvedQaChannelAccount>,
"id" | "meta" | "capabilities" | "reload" | "configSchema" | "setup" | "config"
"id" | "meta" | "capabilities" | "reload" | "configSchema" | "setup" | "setupContract" | "config"
>;
export function createQaChannelPluginBase(
meta: ChannelPlugin<ResolvedQaChannelAccount>["meta"] = qaChannelSetupMeta,
): QaChannelPluginBase {
const setup = {
applyAccountConfig: ({ cfg, accountId, input }) =>
applyQaSetup({
cfg,
accountId,
input: input as QaChannelSetupInput,
}),
} satisfies NonNullable<ChannelPlugin["setup"]>;
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 <url>", description: "QA channel base URL" },
},
botUserId: {
kind: "string",
cli: { flags: "--bot-user-id <id>", description: "QA channel bot user id" },
},
botDisplayName: {
kind: "string",
cli: { flags: "--bot-display-name <name>", description: "QA channel bot display name" },
},
},
legacyAdapter: setup,
}),
config: {
listAccountIds: (cfg) => listQaChannelAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) =>

View File

@@ -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: {

View File

@@ -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;
}

View File

@@ -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 <appId:secret>", "description": "QQBot app id and client secret" } },
{ "key": "tokenFile", "kind": "string", "sensitive": true, "cli": { "flags": "--token-file <path>", "description": "QQBot client secret file" } },
{ "key": "useEnv", "kind": "boolean", "cli": { "flags": "--use-env", "description": "Use QQBOT environment credentials" } }
] }
},
"install": {
"npmSpec": "@openclaw/qqbot",

View File

@@ -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 <appId:secret>", description: "QQBot app id and client secret" },
},
tokenFile: {
kind: "string",
sensitive: true,
cli: { flags: "--token-file <path>", description: "QQBot client secret file" },
},
useEnv: {
kind: "boolean",
cli: { flags: "--use-env", description: "Use QQBOT environment credentials" },
},
},
legacyAdapter: qqbotSetupAdapterShared,
});

View File

@@ -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<ResolvedQQBotAccount> = {
setup: {
...qqbotSetupAdapterShared,
},
setupContract: qqbotSetupContract,
};

View File

@@ -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<ResolvedQQBotAccount> = {
setup: {
...qqbotSetupAdapterShared,
},
setupContract: qqbotSetupContract,
approvalCapability: getQQBotApprovalCapability(),
groups: {
resolveToolPolicy: resolveQQBotGroupToolPolicy,

View File

@@ -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 <profile>", "description": "Raft CLI profile" }
}
]
}
},
"release": {
"publishToClawHub": true,

View File

@@ -39,6 +39,7 @@ export const raftPlugin: ChannelPlugin<ResolvedRaftAccount, RaftProbe> = createC
chatTypes: ["direct"],
},
setup: raftSetupPlugin.setup,
setupContract: raftSetupPlugin.setupContract,
setupWizard: raftSetupPlugin.setupWizard,
reload: { configPrefixes: ["channels.raft"] },
configSchema: raftChannelConfigSchema,

View File

@@ -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 <profile>", description: "Raft CLI profile" },
},
},
legacyAdapter: raftSetupAdapter,
});
export const raftSetupPlugin: ChannelPlugin<ResolvedRaftAccount> = {
id: RAFT_CHANNEL_ID,
meta: {
@@ -56,6 +67,7 @@ export const raftSetupPlugin: ChannelPlugin<ResolvedRaftAccount> = {
chatTypes: ["direct"],
},
setup: raftSetupAdapter,
setupContract: raftSetupContract,
config: {
listAccountIds: listRaftAccountIds,
resolveAccount: (cfg, accountId) => resolveRaftAccount({ cfg, accountId }),

View File

@@ -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": {

View File

@@ -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<ReefAccount> = {
reload: { configPrefixes: ["channels.reef"] },
configSchema: buildChannelConfigSchema(ReefChannelConfigSchema),
setup: reefSetupAdapter,
setupContract: reefSetupContract,
setupWizard: reefSetupWizard as never,
config: {
listAccountIds: () => ["default"],

View File

@@ -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 }) => {

View File

@@ -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";

View File

@@ -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<string, unknown>): 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();
});
});

View File

@@ -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<string, unknown> {
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 } : {}),
};
}

View File

@@ -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 <path>",
"description": "Channel CLI path"
},
{
"flags": "--http-url <url>",
"description": "Channel HTTP service URL"
},
{
"flags": "--signal-number <e164>",
"description": "Signal account number (E.164)"
},
{
"flags": "--http-host <host>",
"description": "Signal HTTP daemon host"
},
{
"flags": "--http-port <port>",
"description": "Signal HTTP daemon port"
}
]
"setup": {
"fields": [
{
"key": "signalNumber",
"kind": "string",
"cli": {
"flags": "--signal-number <e164>",
"description": "Signal account number (E.164)"
}
},
{
"key": "signalTransport",
"kind": "choice",
"choices": ["external-native", "container"],
"cli": {
"flags": "--signal-transport <kind>",
"description": "Signal HTTP transport (external-native or container)"
}
},
{
"key": "cliPath",
"kind": "string",
"cli": {
"flags": "--cli-path <path>",
"description": "signal-cli executable path"
}
},
{
"key": "httpUrl",
"kind": "string",
"cli": {
"flags": "--http-url <url>",
"description": "Signal HTTP service URL"
}
},
{
"key": "httpHost",
"kind": "string",
"cli": {
"flags": "--http-host <host>",
"description": "Signal HTTP daemon host"
}
},
{
"key": "httpPort",
"kind": "string",
"cli": {
"flags": "--http-port <port>",
"description": "Signal HTTP daemon port"
}
}
]
}
},
"install": {
"clawhubSpec": "clawhub:@openclaw/signal",

View File

@@ -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<NonNullable<OpenClawConfig["channels"]>["signal"], undefined>,
"accounts"
>;
type SignalChannelConfig = Exclude<NonNullable<OpenClawConfig["channels"]>["signal"], undefined>;
export type SignalAccountConfig = Omit<SignalChannelConfig, "accounts" | "defaultAccount">;
export type SignalTransportConfig = NonNullable<SignalChannelConfig["transport"]>;

View File

@@ -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);
});

View File

@@ -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<SignalAccountConfig>({
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<SignalAccountConfig>({
channelConfig: (accountId === DEFAULT_ACCOUNT_ID ? channelConfig : shared) as
| SignalAccountConfig
| undefined,
accounts: cfg.channels?.signal?.accounts as
| Record<string, Partial<SignalAccountConfig>>
| 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<number>();
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,
};

View File

@@ -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<ResolvedSignalAccount> = {
...createSignalPluginBase({
setupWizard: signalSetupWizard,
setup: signalSetupAdapter,
setupContract: signalSetupContract,
}),
};

View File

@@ -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<ResolvedSignalAccount, SignalProbe> =
base: {
...createSignalPluginBase({
setupWizard: signalSetupWizard,
setup: signalSetupAdapter,
setupContract: signalSetupContract,
}),
actions: signalMessageActions,
approvalCapability: signalApprovalCapability,
@@ -582,7 +582,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
const baseUrl = account.baseUrl;
const { probeSignal } = await loadSignalProbeModule();
return await probeSignal(baseUrl, timeoutMs, {
apiMode: account.config?.apiMode ?? "auto",
transportKind: account.transport.kind,
});
},
formatCapabilitiesProbe: ({ probe }) =>

View File

@@ -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<typeof import("./client.js")>();
return {
...actual,
signalCheck: mockNativeCheck,
signalRpcRequest: mockNativeRpcRequest,
streamSignalEvents: mockNativeStreamEvents,
};
const nativeCheck = vi.fn<typeof nativeClient.signalCheck>();
const nativeRpc = vi.fn<typeof nativeClient.signalRpcRequest>();
const nativeStream = vi.fn<typeof nativeClient.streamSignalEvents>();
const containerCheck = vi.fn<typeof containerClient.containerCheck>();
const containerRpc = vi.fn<typeof containerClient.containerRpcRequest>();
const containerStream = vi.fn<typeof containerClient.streamContainerEvents>();
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<typeof import("./client-container.js")>();
return {
...actual,
containerCheck: mockContainerCheck,
containerRpcRequest: mockContainerRpcRequest,
streamContainerEvents: mockStreamContainerEvents,
};
});
let currentApiMode: SignalApiMode = "auto";
afterEach(() => {
vi.useRealTimers();
});
function setApiMode(mode: SignalApiMode) {
currentApiMode = mode;
}
function signalRpcRequest<T = unknown>(
method: string,
params: Record<string, unknown> | undefined,
opts: Parameters<typeof signalRpcRequestImpl>[2],
) {
return signalRpcRequestImpl<T>(method, params, { ...opts, apiMode: currentApiMode });
}
function signalCheck(baseUrl: string, timeoutMs?: number) {
return signalCheckImpl(baseUrl, timeoutMs, { apiMode: currentApiMode });
}
function streamSignalEvents(params: Parameters<typeof streamSignalEventsImpl>[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<string, unknown> {
return typeof value === "object" && value !== null;
}
function requireRecord(value: unknown, label: string): Record<string, unknown> {
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<string, unknown>, expected: Record<string, unknown>) {
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<string, unknown>;
options?: Record<string, unknown>;
}) {
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<string, unknown>) {
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();
});
});

View File

@@ -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<T = unknown>(
params: Record<string, unknown> | undefined,
opts: SignalRpcOptions & {
accountId?: string;
apiMode?: SignalApiMode;
transportKind?: SignalTransportKind;
maxAttachmentBytes?: number;
},
): Promise<T> {
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<T>(method, params, opts);
}
return containerRpcRequest<T>(method, params, opts);
return usesContainer(opts.transportKind)
? containerRpcRequest<T>(method, params, opts)
: nativeRpcRequest<T>(method, params, opts);
}
/**
@@ -194,22 +56,15 @@ export async function signalRpcRequest<T = unknown>(
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<void> {
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,

View File

@@ -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<SignalTransportConfig>;
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown>, parent: Record<string, unknown>, key: string) {
return Object.hasOwn(entry, key) ? entry[key] : parent[key];
}
function legacyBaseUrl(entry: Record<string, unknown>, parent: Record<string, unknown>): 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<string, unknown>): boolean {
return LEGACY_TRANSPORT_FIELDS.some((field) => Object.hasOwn(entry, field));
}
function wasLegacySignalAccountConfigured(
entry: Record<string, unknown>,
parent: Record<string, unknown>,
): 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<string, unknown>[],
parent: Record<string, unknown>,
): 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<string, unknown>[],
parent: Record<string, unknown>,
): "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<SignalTransportConfig | undefined>,
): boolean {
return transports.some(
(transport) =>
transport?.kind === "managed-native" &&
transport.httpPort !== undefined &&
!isValidSignalManagedNativePort(transport.httpPort),
);
}
function requiresDetection(
entry: Record<string, unknown>,
parent: Record<string, unknown>,
apiMode: unknown,
): boolean {
if (apiMode !== undefined && apiMode !== "auto") {
return false;
}
return (
Boolean(optionalString(inherited(entry, parent, "httpUrl"))) ||
!resolveLegacyAutoStart(entry, parent)
);
}
function resolveLegacyAutoStart(
entry: Record<string, unknown>,
parent: Record<string, unknown>,
): boolean {
const autoStart = inherited(entry, parent, "autoStart");
if (typeof autoStart === "boolean") {
return autoStart;
}
return !optionalString(inherited(entry, parent, "httpUrl"));
}
function resolveManagedConnectionUrl(
entry: Record<string, unknown>,
parent: Record<string, unknown>,
): 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<string, unknown>,
parent: Record<string, unknown>,
): 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<string, unknown>;
parent: Record<string, unknown>;
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<string, unknown>;
parent: Record<string, unknown>;
apiMode: unknown;
detect?: DetectTransport;
}): Promise<SignalTransportConfig | undefined> {
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<string, unknown>): void {
for (const field of LEGACY_TRANSPORT_FIELDS) {
delete entry[field];
}
}
function hasRootSignalAccount(entries: Record<string, unknown>[]): boolean {
const root = entries[0];
return (
entries.length === 1 ||
Boolean(optionalString(root?.account)) ||
isSignalTransportConfig(root?.transport)
);
}
function signalAccountIds(entries: Record<string, unknown>[]): 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<string, unknown>,
accountId: string,
): string | undefined {
const normalizedAccountId = normalizeAccountId(accountId);
return Object.keys(accounts).find(
(key) => Boolean(key.trim()) && normalizeAccountId(key) === normalizedAccountId,
);
}
function nestedDefaultOwnsEffectiveTransport(entries: Record<string, unknown>[]): 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<string, unknown>[], 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<string, unknown>[], 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<string, unknown>[];
transports: Array<SignalTransportConfig | undefined>;
}): Array<SignalTransportConfig | undefined> {
const reservedPorts = new Set<number>();
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<string, unknown>[];
transports: Array<SignalTransportConfig | undefined>;
}): 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<ChannelDoctorConfigMutation> {
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.",
],
};
}

View File

@@ -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);
});
});

View File

@@ -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<string, unknown> {
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<typeof SignalConfigSchemaBase>;
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,
});

View File

@@ -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<string, ChannelConfigUiHint>;

View File

@@ -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");
});

View File

@@ -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 });
},
};

View File

@@ -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();

View File

@@ -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,
},
);

View File

@@ -98,16 +98,42 @@ export function createSignalToolResultConfig(
const base = config as { channels?: Record<string, unknown> };
const channels = base.channels ?? {};
const signal = (channels.signal ?? {}) as Record<string, unknown>;
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: ["*"],
},
},
};

View File

@@ -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)),

View File

@@ -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<SignalProbe> {
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<SignalTransportKind> {
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";
}

View File

@@ -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";

View File

@@ -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 });

View File

@@ -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<SignalReactionResult> {
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 {

View File

@@ -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"],
},

View File

@@ -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<SignalSendResult> {
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<boolean> {
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;
}

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