mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 08:21:35 +00:00
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:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"core": 2303,
|
||||
"channel": 3627,
|
||||
"channel": 3632,
|
||||
"plugin": 3556
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -53,7 +53,8 @@
|
||||
"lark"
|
||||
],
|
||||
"order": 35,
|
||||
"quickstartAllowFrom": true
|
||||
"quickstartAllowFrom": true,
|
||||
"setup": { "fields": [] }
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/feishu",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
30
extensions/line/src/setup-core.test.ts
Normal file
30
extensions/line/src/setup-core.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -59,7 +59,8 @@
|
||||
"groupModel": "hybrid",
|
||||
"groupAllowFromFallbackToAllowFrom": true,
|
||||
"warnOnEmptyGroupSenderAllowlist": true
|
||||
}
|
||||
},
|
||||
"setup": { "fields": [] }
|
||||
},
|
||||
"install": {
|
||||
"npmSpec": "@openclaw/msteams",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" } }
|
||||
] }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]>;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
743
extensions/signal/src/config-compat.ts
Normal file
743
extensions/signal/src/config-compat.ts
Normal 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.",
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
12
extensions/signal/src/doctor.ts
Normal file
12
extensions/signal/src/doctor.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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: ["*"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user