diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b36b46df92..12e0eae6b09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai - Agents/tools: add per-sender tool policies with canonical channel-scoped sender keys, so operators can restrict dangerous tools by requester identity across global, agent, group, core, bundled, and plugin tool surfaces. (#66933) Thanks @JerranC. - ACP: expose Gateway session lineage metadata through ACP session listings and session info snapshots so clients can render subagent graphs without private Gateway side channels. (#73458) Thanks @samzong. +- Channels/iMessage: add `openclaw channels status --channel ` filtering and document the BlueBubbles-to-imsg cutover path so operators can probe iMessage without starting both channel monitors. (#80706) Thanks @omarshahine. - CI: add a non-blocking `plugin-inspector-advisory` artifact to Plugin Prerelease so release runs capture bundled plugin compatibility triage without changing the blocking gate. - Runtime/Fly: detect Fly Machines as container environments from their runtime env vars, so gateway bind and Bonjour defaults match remote container launches. (#80209) Thanks @liorb-mountapps. - Providers/fal: route GPT Image 2 and Nano Banana 2 reference-image edit requests to `/edit` with `image_urls` array, enforce NB2 edit geometry using `aspect_ratio` and `resolution` params, lift Fal edit mode input-image caps to 10 for GPT Image 2 and 14 for Nano Banana 2, and allow aspect-ratio hints in edit mode. (#77295) Thanks @leoge007. diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index ed55af639c6..d1db014896c 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -3627,18 +3627,22 @@ public struct TalkSpeakResult: Codable, Sendable { public struct ChannelsStatusParams: Codable, Sendable { public let probe: Bool? public let timeoutms: Int? + public let channel: String? public init( probe: Bool?, - timeoutms: Int?) + timeoutms: Int?, + channel: String?) { self.probe = probe self.timeoutms = timeoutms + self.channel = channel } private enum CodingKeys: String, CodingKey { case probe case timeoutms = "timeoutMs" + case channel } } diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index 740057ca623..65ad8c14e75 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -11,10 +11,18 @@ "source": "Coming from BlueBubbles", "target": "Coming from BlueBubbles" }, + { + "source": "BlueBubbles removal and the imsg iMessage path", + "target": "BlueBubbles removal and the imsg iMessage path" + }, { "source": "BlueBubbles", "target": "BlueBubbles" }, + { + "source": "Configuration reference - iMessage", + "target": "Configuration reference - iMessage" + }, { "source": "Pairing", "target": "配对" diff --git a/docs/announcements/bluebubbles-imessage.md b/docs/announcements/bluebubbles-imessage.md new file mode 100644 index 00000000000..4849a5102f2 --- /dev/null +++ b/docs/announcements/bluebubbles-imessage.md @@ -0,0 +1,79 @@ +--- +summary: "BlueBubbles support was removed from OpenClaw. Use the bundled iMessage plugin with imsg for new and migrated iMessage setups." +read_when: + - You used the old BlueBubbles channel and need to move to iMessage + - You are choosing the supported OpenClaw iMessage setup + - You need a short explanation of the BlueBubbles removal +title: "BlueBubbles removal and the imsg iMessage path" +--- + +# BlueBubbles removal and the imsg iMessage path + +OpenClaw no longer ships the BlueBubbles channel. iMessage support now runs through the bundled `imessage` plugin, which starts [`imsg`](https://github.com/steipete/imsg) locally or through an SSH wrapper and talks JSON-RPC over stdin/stdout. + +If your config still contains `channels.bluebubbles`, migrate it to `channels.imessage`. The legacy `/channels/bluebubbles` docs URL redirects to [Coming from BlueBubbles](/channels/imessage-from-bluebubbles), which has the full config translation table and cutover checklist. + +## What changed + +- There is no BlueBubbles HTTP server, webhook route, REST password, or BlueBubbles plugin runtime in the supported OpenClaw iMessage path. +- OpenClaw reads and watches Messages through `imsg` on the Mac where Messages.app is signed in. +- Basic send, receive, history, and media use the normal `imsg` surfaces and macOS permissions. +- Advanced actions such as threaded replies, tapbacks, edit, unsend, effects, read receipts, typing indicators, and group management require `imsg launch` with the private API bridge available. +- Linux and Windows gateways can still use iMessage by setting `channels.imessage.cliPath` to an SSH wrapper that runs `imsg` on the signed-in Mac. + +## What to do + +1. Install and verify `imsg` on the Messages Mac: + + ```bash + brew install steipete/tap/imsg + imsg --version + imsg chats --limit 3 + imsg rpc --help + ``` + +2. Grant Full Disk Access and Automation permissions to the process context that runs `imsg` and OpenClaw. + +3. Translate the old config: + + ```json5 + { + channels: { + imessage: { + enabled: true, + cliPath: "/opt/homebrew/bin/imsg", + dmPolicy: "pairing", + allowFrom: ["+15555550123"], + groupPolicy: "allowlist", + groupAllowFrom: ["+15555550123"], + groups: { + "*": { requireMention: true }, + }, + includeAttachments: true, + }, + }, + } + ``` + +4. Restart the gateway and verify: + + ```bash + openclaw channels status --probe + ``` + +5. Test DMs, groups, attachments, and any private API actions you depend on before deleting your old BlueBubbles server. + +## Migration notes + +- `channels.bluebubbles.serverUrl` and `channels.bluebubbles.password` have no iMessage equivalent. +- `channels.bluebubbles.allowFrom`, `groupAllowFrom`, `groups`, `includeAttachments`, attachment roots, media size limits, chunking, and action toggles have iMessage equivalents. +- `channels.imessage.includeAttachments` is still off by default. Set it explicitly if you expect inbound photos, voice memos, videos, or files to reach the agent. +- With `groupPolicy: "allowlist"`, copy the old `groups` block, including any `"*"` wildcard entry. Group sender allowlists and the group registry are separate gates. +- ACP bindings that matched `channel: "bluebubbles"` must be changed to `channel: "imessage"`. +- Old BlueBubbles session keys do not become iMessage session keys. Pairing approvals carry over by handle, but conversation history under BlueBubbles session keys does not. + +## See also + +- [Coming from BlueBubbles](/channels/imessage-from-bluebubbles) +- [iMessage](/channels/imessage) +- [Configuration reference - iMessage](/gateway/config-channels#imessage) diff --git a/docs/channels/imessage-from-bluebubbles.md b/docs/channels/imessage-from-bluebubbles.md index df8c1dcab71..f40b1ac08bc 100644 --- a/docs/channels/imessage-from-bluebubbles.md +++ b/docs/channels/imessage-from-bluebubbles.md @@ -11,6 +11,22 @@ The bundled `imessage` plugin now reaches the same private API surface as BlueBu BlueBubbles support was removed. OpenClaw supports iMessage through `imsg` only. This guide is for migrating old `channels.bluebubbles` configs to `channels.imessage`; there is no other supported migration path. + +For the short announcement and operator summary, see [BlueBubbles removal and the imsg iMessage path](/announcements/bluebubbles-imessage). + + +## Migration checklist + +Use this checklist when you already know your old BlueBubbles config and want the shortest safe path: + +1. Verify `imsg` directly on the Mac that runs Messages.app (`imsg chats`, `imsg history`, `imsg send`, and `imsg rpc --help`). +2. Copy behavior keys from `channels.bluebubbles` to `channels.imessage`: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`, `includeAttachments`, `attachmentRoots`, `mediaMaxMb`, `textChunkLimit`, `coalesceSameSenderDms`, and `actions`. +3. Drop transport keys that no longer exist: `serverUrl`, `password`, webhook URLs, and BlueBubbles server setup. +4. If the Gateway is not running on the Messages Mac, set `channels.imessage.cliPath` to an SSH wrapper and set `remoteHost` for remote attachment fetches. +5. With the Gateway stopped, enable `channels.imessage`, then run `openclaw channels status --probe --channel imessage`. +6. Test one DM, one allowed group, attachments if enabled, and every private API action you expect the agent to use. +7. Delete the BlueBubbles server and old `channels.bluebubbles` config after the iMessage path is verified. + ## When this migration makes sense - You already run `imsg` on the same Mac (or one reachable over SSH) where Messages.app is signed in. @@ -60,13 +76,13 @@ BlueBubbles support was removed. OpenClaw supports iMessage through `imsg` only. `imsg launch` requires SIP to be disabled. Basic send, history, and watch work without `imsg launch`; advanced actions do not. -4. Verify the bridge through OpenClaw: +4. After you add an enabled `channels.imessage` config, verify the bridge through OpenClaw: ```bash openclaw channels status --probe ``` - You want `imessage.privateApi.available: true`. If it reports `false`, fix that first — see [Capability detection](/channels/imessage#private-api-actions). + You want `imessage.privateApi.available: true`. If it reports `false`, fix that first — see [Capability detection](/channels/imessage#private-api-actions). `channels status --probe` only probes configured, enabled accounts. 5. Snapshot your config: @@ -143,7 +159,7 @@ If the gateway logs `imessage: dropping group message from chat_id=` or the ## Step-by-step -1. Add an iMessage block alongside the existing BlueBubbles block. Keep the old block only as a copy source until the new path is verified: +1. Add an iMessage block alongside the existing BlueBubbles block. Keep it disabled while the Gateway is still routing BlueBubbles traffic: ```json5 { @@ -153,7 +169,7 @@ If the gateway logs `imessage: dropping group message from chat_id=` or the // ... existing config ... }, imessage: { - enabled: false, // turn on after the dry run below + enabled: false, cliPath: "/opt/homebrew/bin/imsg", dmPolicy: "pairing", allowFrom: ["+15555550123"], // copy from bluebubbles.allowFrom @@ -173,17 +189,17 @@ If the gateway logs `imessage: dropping group message from chat_id=` or the } ``` -2. **Dry-run probe** — start the gateway and confirm iMessage reports healthy: +2. **Probe before traffic matters** — stop the Gateway, temporarily enable the iMessage block, and confirm iMessage reports healthy from the CLI: ```bash - openclaw gateway - openclaw channels status - openclaw channels status --probe # expect imessage.privateApi.available: true + openclaw gateway stop + # edit config: channels.imessage.enabled = true + openclaw channels status --probe --channel imessage # expect imessage.privateApi.available: true ``` - Because `imessage.enabled` is still `false`, no inbound iMessage traffic is routed yet — but `--probe` exercises the bridge so you catch permission/install issues before the cutover. + `channels status --probe` only probes configured, enabled accounts. Do not restart the Gateway with both BlueBubbles and iMessage enabled unless you intentionally want both channel monitors running. If you are not cutting over immediately, set `channels.imessage.enabled` back to `false` before restarting the Gateway. Use the direct `imsg` commands in [Before you start](#before-you-start) to validate the Mac before enabling OpenClaw traffic. -3. **Cut over.** Remove the BlueBubbles config and enable iMessage in one config edit: +3. **Cut over.** Once the enabled iMessage account reports healthy, remove the BlueBubbles config and keep iMessage enabled: ```json5 { @@ -236,6 +252,7 @@ The reply cache lives at `~/.openclaw/state/imessage/reply-cache.jsonl` (mode `0 ## Related +- [BlueBubbles removal and the imsg iMessage path](/announcements/bluebubbles-imessage) — short announcement and operator summary. - [iMessage](/channels/imessage) — full iMessage channel reference, including `imsg launch` setup and capability detection. - `/channels/bluebubbles` — legacy URL that redirects to this migration guide. - [Pairing](/channels/pairing) — DM authentication and pairing flow. diff --git a/docs/channels/imessage.md b/docs/channels/imessage.md index 46adf6e9ef1..3e22de9ada9 100644 --- a/docs/channels/imessage.md +++ b/docs/channels/imessage.md @@ -13,7 +13,7 @@ For OpenClaw iMessage deployments, use `imsg` on a signed-in macOS Messages host -BlueBubbles support was removed. Migrate `channels.bluebubbles` configs to `channels.imessage`; OpenClaw supports iMessage through `imsg` only. +BlueBubbles support was removed. Migrate `channels.bluebubbles` configs to `channels.imessage`; OpenClaw supports iMessage through `imsg` only. Start with [BlueBubbles removal and the imsg iMessage path](/announcements/bluebubbles-imessage) for the short announcement, or [Coming from BlueBubbles](/channels/imessage-from-bluebubbles) for the full migration table. Status: native external CLI integration. Gateway spawns `imsg rpc` and communicates over JSON-RPC on stdio (no separate daemon/port). Advanced actions require `imsg launch` and a successful private API probe. @@ -780,6 +780,7 @@ openclaw channels status --probe --channel imessage ## Related - [Channels Overview](/channels) — all supported channels +- [BlueBubbles removal and the imsg iMessage path](/announcements/bluebubbles-imessage) — announcement and migration summary - [Coming from BlueBubbles](/channels/imessage-from-bluebubbles) — config translation table and step-by-step cutover - [Pairing](/channels/pairing) — DM authentication and pairing flow - [Groups](/channels/groups) — group chat behavior and mention gating diff --git a/docs/cli/channels.md b/docs/cli/channels.md index cced73bdb28..59a681c4a3e 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -32,7 +32,7 @@ openclaw channels logs --channel all ## Status / capabilities / resolve / logs -- `channels status`: `--probe`, `--timeout `, `--json` +- `channels status`: `--channel `, `--probe`, `--timeout `, `--json` - `channels capabilities`: `--channel `, `--account ` (only with `--channel`), `--target `, `--timeout `, `--json` - `channels resolve`: ``, `--channel `, `--account `, `--kind `, `--json` - `channels logs`: `--channel `, `--lines `, `--json` diff --git a/docs/docs.json b/docs/docs.json index e37a4c3d694..13c7482f3ea 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1050,7 +1050,7 @@ "groups": [ { "group": "Overview", - "pages": ["channels/index"] + "pages": ["channels/index", "announcements/bluebubbles-imessage"] }, { "group": "Mainstream messaging", diff --git a/docs/gateway/config-channels.md b/docs/gateway/config-channels.md index a2a18eb57f2..59bdfe45749 100644 --- a/docs/gateway/config-channels.md +++ b/docs/gateway/config-channels.md @@ -588,7 +588,7 @@ When Mattermost native commands are enabled: OpenClaw spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required. This is the preferred path for new OpenClaw iMessage setups when the host can grant Messages database and Automation permissions. -BlueBubbles support was removed. Migrate `channels.bluebubbles` configs to `channels.imessage`; OpenClaw supports iMessage through `imsg` only. +BlueBubbles support was removed. `channels.bluebubbles` is not a supported runtime config surface on current OpenClaw. Migrate old configs to `channels.imessage`; use [BlueBubbles removal and the imsg iMessage path](/announcements/bluebubbles-imessage) for the short version and [Coming from BlueBubbles](/channels/imessage-from-bluebubbles) for the full translation table. If the Gateway is not running on the signed-in Messages Mac, keep `channels.imessage.enabled=true` and set `channels.imessage.cliPath` to an SSH wrapper that runs `imsg "$@"` on that Mac. The default local `imsg` path is macOS-only. @@ -609,6 +609,17 @@ If the Gateway is not running on the signed-in Messages Mac, keep `channels.imes mediaMaxMb: 16, service: "auto", region: "US", + actions: { + reactions: true, + edit: true, + unsend: true, + reply: true, + sendWithEffect: true, + sendAttachment: true, + }, + catchup: { + enabled: false, + }, }, }, } @@ -622,6 +633,10 @@ If the Gateway is not running on the signed-in Messages Mac, keep `channels.imes - `attachmentRoots` and `remoteAttachmentRoots` restrict inbound attachment paths (default: `/Users/*/Library/Messages/Attachments`). - SCP uses strict host-key checking, so ensure the relay host key already exists in `~/.ssh/known_hosts`. - `channels.imessage.configWrites`: allow or deny iMessage-initiated config writes. +- `channels.imessage.actions.*`: enable private API actions that are also gated by `imsg status` / `openclaw channels status --probe`. +- `channels.imessage.includeAttachments` is off by default; set it to `true` before expecting inbound media in agent turns. +- `channels.imessage.catchup.enabled`: opt in to replaying inbound messages that arrived while the Gateway was down. +- `channels.imessage.groups`: group registry and per-group settings. With `groupPolicy: "allowlist"`, configure either explicit `chat_id` keys or a `"*"` wildcard entry so group messages can pass the registry gate. - Top-level `bindings[]` entries with `type: "acp"` can bind iMessage conversations to persistent ACP sessions. Use a normalized handle or explicit chat target (`chat_id:*`, `chat_guid:*`, `chat_identifier:*`) in `match.peer.id`. Shared field semantics: [ACP Agents](/tools/acp-agents#persistent-channel-bindings). diff --git a/src/cli/channels-cli.ts b/src/cli/channels-cli.ts index fc5a33eb055..f4a76f6e4f3 100644 --- a/src/cli/channels-cli.ts +++ b/src/cli/channels-cli.ts @@ -130,6 +130,7 @@ export async function registerChannelsCli( channels .command("status") .description("Show gateway channel status (use status --deep for local)") + .option("--channel ", `Only show one channel (${formatCliChannelOptions(["all"])})`) .option("--probe", "Probe channel credentials", false) .option("--timeout ", "Timeout in ms", "10000") .option("--json", "Output JSON", false) diff --git a/src/cli/program/route-args.ts b/src/cli/program/route-args.ts index 0f0d6d3aca8..fbb5d42c87d 100644 --- a/src/cli/program/route-args.ts +++ b/src/cli/program/route-args.ts @@ -257,10 +257,15 @@ export function parseChannelsListRouteArgs(argv: string[]) { export function parseChannelsStatusRouteArgs(argv: string[]) { const timeout = parseOptionalFlagValue(argv, "--timeout"); + const channel = parseOptionalFlagValue(argv, "--channel"); if (!timeout.ok) { return null; } + if (!channel.ok) { + return null; + } return { + channel: channel.value, json: hasFlag(argv, "--json"), probe: hasFlag(argv, "--probe"), timeout: timeout.value, diff --git a/src/cli/program/routes.test.ts b/src/cli/program/routes.test.ts index 059b847c0ed..bc17d8fb560 100644 --- a/src/cli/program/routes.test.ts +++ b/src/cli/program/routes.test.ts @@ -138,12 +138,14 @@ describe("program routes", () => { "status", "--json", "--probe", + "--channel", + "imsg", "--timeout", "5000", ]), ).resolves.toBe(true); expect(channelsStatusCommandMock).toHaveBeenCalledWith( - { json: true, probe: true, timeout: "5000" }, + { channel: "imsg", json: true, probe: true, timeout: "5000" }, defaultRuntime, ); }); diff --git a/src/commands/channels.status.command-flow.test.ts b/src/commands/channels.status.command-flow.test.ts index 93a99b0d520..1012f7ffc3c 100644 --- a/src/commands/channels.status.command-flow.test.ts +++ b/src/commands/channels.status.command-flow.test.ts @@ -106,6 +106,7 @@ vi.mock("../channels/plugins/index.js", () => ({ listChannelPlugins: () => mocks.listChannelPlugins(), getChannelPlugin: (channel: string) => (mocks.listChannelPlugins() as Array<{ id: string }>).find((plugin) => plugin.id === channel), + normalizeChannelId: (channel: string) => (channel === "imsg" ? "imessage" : channel), })); vi.mock("../channels/plugins/read-only.js", () => ({ @@ -210,6 +211,23 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { mocks.listChannelPlugins.mockReturnValue([createTokenOnlyPlugin()]); }); + it("passes a channel filter to the gateway status request", async () => { + mocks.callGateway.mockResolvedValue({ + channelAccounts: { imessage: [] }, + channels: { imessage: {} }, + }); + const { runtime } = createCapturingTestRuntime(); + + await channelsStatusCommand({ channel: "imsg", json: true, probe: true }, runtime as never); + + expect(mocks.callGateway).toHaveBeenCalledWith( + expect.objectContaining({ + method: "channels.status", + params: { channel: "imsg", probe: true, timeoutMs: 30000 }, + }), + ); + }); + it("keeps read-only fallback output when SecretRefs are unresolved", async () => { mocks.callGateway.mockRejectedValue(new Error("gateway closed")); mocks.requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} }); @@ -300,7 +318,7 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { }); const { runtime, logs, errors } = createCapturingTestRuntime(); - await channelsStatusCommand({ json: true, probe: false }, runtime as never); + await channelsStatusCommand({ channel: "imsg", json: true, probe: false }, runtime as never); expect(mocks.listChannelPlugins).not.toHaveBeenCalled(); expect(mocks.listConfiguredChannelIdsForReadOnlyScope).toHaveBeenCalledOnce(); @@ -322,6 +340,6 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { expect(payload.error).not.toContain("fallback-secret"); expect(payload.gatewayReachable).toBe(false); expect(payload.configOnly).toBe(true); - expect(payload.configuredChannels).toStrictEqual(["discord"]); + expect(payload.configuredChannels).toStrictEqual([]); }); }); diff --git a/src/commands/channels/status-config-format.ts b/src/commands/channels/status-config-format.ts index fb5a8c23aad..24ebb689aa3 100644 --- a/src/commands/channels/status-config-format.ts +++ b/src/commands/channels/status-config-format.ts @@ -2,6 +2,7 @@ import { hasConfiguredUnavailableCredentialStatus, hasResolvedCredentialValue, } from "../../channels/account-snapshot-fields.js"; +import { normalizeChannelId } from "../../channels/plugins/index.js"; import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js"; import { buildChannelAccountSnapshot, @@ -33,7 +34,7 @@ type ChannelStatusPluginLabel = { export async function formatConfigChannelsStatusLines( cfg: OpenClawConfig, meta: { path?: string; mode?: "local" | "remote" }, - opts?: { sourceConfig?: OpenClawConfig }, + opts?: { sourceConfig?: OpenClawConfig; channel?: string }, ): Promise { const lines: string[] = []; lines.push(theme.warn("Gateway not reachable; showing config-only status.")); @@ -63,10 +64,11 @@ export async function formatConfigChannelsStatusLines( }); const sourceConfig = opts?.sourceConfig ?? cfg; + const requestedChannel = opts?.channel ? normalizeChannelId(opts.channel) : null; const plugins = listReadOnlyChannelPluginsForConfig(cfg, { activationSourceConfig: sourceConfig, includeSetupFallbackPlugins: true, - }); + }).filter((plugin) => !requestedChannel || plugin.id === requestedChannel); const visibleChannelIds = new Set(); for (const plugin of plugins) { visibleChannelIds.add(plugin.id); @@ -108,6 +110,9 @@ export async function formatConfigChannelsStatusLines( ]), ]; for (const channelId of missingChannelIds) { + if (requestedChannel && channelId !== requestedChannel) { + continue; + } if (visibleChannelIds.has(channelId)) { continue; } diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index 12c6f73a226..b8e77a98d66 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -1,3 +1,4 @@ +import { normalizeChannelId } from "../../channels/plugins/index.js"; import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolution.js"; import { formatCliCommand } from "../../cli/command-format.js"; import { getConfiguredChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; @@ -24,6 +25,7 @@ import { import { formatConfigChannelsStatusLines } from "./status-config-format.js"; export type ChannelsStatusOptions = { + channel?: string; json?: boolean; probe?: boolean; timeout?: string; @@ -205,6 +207,7 @@ export async function channelsStatusCommand( runtime: RuntimeEnv = defaultRuntime, ) { const timeoutMs = Number(opts.timeout ?? (opts.probe ? 30_000 : 10_000)); + const requestedChannel = opts.channel ? normalizeChannelId(opts.channel) : null; const statusLabel = opts.probe ? "Checking channel status (probe)…" : "Checking channel status…"; const shouldLogStatus = opts.json !== true && !process.stderr.isTTY; if (shouldLogStatus) { @@ -217,12 +220,20 @@ export async function channelsStatusCommand( indeterminate: true, enabled: opts.json !== true, }, - async () => - await callGateway({ - method: "channels.status", - params: { probe: Boolean(opts.probe), timeoutMs }, + async () => { + const params: { channel?: string; probe: boolean; timeoutMs: number } = { + probe: Boolean(opts.probe), timeoutMs, - }), + }; + if (opts.channel) { + params.channel = opts.channel; + } + return await callGateway({ + method: "channels.status", + params, + timeoutMs, + }); + }, ); if (opts.json) { writeRuntimeJson(runtime, payload); @@ -259,7 +270,7 @@ export async function channelsStatusCommand( activationSourceConfig: cfg, env: process.env, includePersistedAuthState: false, - }), + }).filter((channelId) => !requestedChannel || channelId === requestedChannel), }); return; } @@ -271,7 +282,7 @@ export async function channelsStatusCommand( path: snapshot.path, mode, }, - { sourceConfig: cfg }, + { sourceConfig: cfg, channel: opts.channel }, ) ).join("\n"), ); diff --git a/src/gateway/protocol/schema/channels.ts b/src/gateway/protocol/schema/channels.ts index 86c21322d75..aac384dfe32 100644 --- a/src/gateway/protocol/schema/channels.ts +++ b/src/gateway/protocol/schema/channels.ts @@ -577,6 +577,7 @@ export const ChannelsStatusParamsSchema = Type.Object( { probe: Type.Optional(Type.Boolean()), timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })), + channel: Type.Optional(NonEmptyString), }, { additionalProperties: false }, ); diff --git a/src/gateway/server-methods/channels.status.test.ts b/src/gateway/server-methods/channels.status.test.ts index 02d4de27509..825aa731f5a 100644 --- a/src/gateway/server-methods/channels.status.test.ts +++ b/src/gateway/server-methods/channels.status.test.ts @@ -176,6 +176,58 @@ describe("channelsHandlers channels.status", () => { expect(probeArgs.cfg).toBe(autoEnabledConfig); }); + it("filters channel status to a requested channel", async () => { + const autoEnabledConfig = { autoEnabled: true }; + const whatsappProbe = vi.fn(async () => ({ ok: true })); + const imessageProbe = vi.fn(async () => ({ ok: true })); + mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] }); + mocks.buildChannelUiCatalog.mockImplementation((plugins: Array<{ id: string }>) => ({ + order: plugins.map((plugin) => plugin.id), + labels: Object.fromEntries(plugins.map((plugin) => [plugin.id, plugin.id])), + detailLabels: Object.fromEntries(plugins.map((plugin) => [plugin.id, plugin.id])), + systemImages: {}, + entries: Object.fromEntries(plugins.map((plugin) => [plugin.id, { id: plugin.id }])), + })); + mocks.listChannelPlugins.mockReturnValue([ + { + id: "whatsapp", + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + isEnabled: () => true, + isConfigured: async () => true, + }, + status: { probeAccount: whatsappProbe }, + }, + { + id: "imessage", + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + isEnabled: () => true, + isConfigured: async () => true, + }, + status: { probeAccount: imessageProbe }, + }, + ]); + const respond = vi.fn(); + + await channelsHandlers["channels.status"]( + createOptions({ channel: "imessage", probe: true, timeoutMs: 1000 }, { respond }), + ); + + expect(whatsappProbe).not.toHaveBeenCalled(); + expect(imessageProbe).toHaveBeenCalledOnce(); + const payload = requireRespondPayload(respond); + expect(payload.channelOrder).toEqual(["imessage"]); + expect(payload.channels).toEqual({ + imessage: expect.any(Object), + }); + expect(payload.channelAccounts).toEqual({ + imessage: [expect.objectContaining({ accountId: "default" })], + }); + }); + it("preserves channel account rows when a live probe throws", async () => { const autoEnabledConfig = { autoEnabled: true }; const probeAccount = vi.fn(async () => { diff --git a/src/gateway/server-methods/channels.ts b/src/gateway/server-methods/channels.ts index bdd490b73c5..94875c75a88 100644 --- a/src/gateway/server-methods/channels.ts +++ b/src/gateway/server-methods/channels.ts @@ -298,14 +298,28 @@ export const channelsHandlers: GatewayRequestHandlers = { const probe = (params as { probe?: boolean }).probe === true; const timeoutMsRaw = (params as { timeoutMs?: unknown }).timeoutMs; const timeoutMs = resolveChannelsStatusTimeoutMs({ probe, timeoutMsRaw }); + const rawChannel = (params as { channel?: unknown }).channel; + const requestedChannel = + typeof rawChannel === "string" ? normalizeChannelId(rawChannel) : undefined; const cfg = applyPluginAutoEnable({ config: context.getRuntimeConfig(), env: process.env, }).config; const runtime = context.getRuntimeSnapshot(); const plugins = listChannelPlugins(); + const selectedPlugins = requestedChannel + ? plugins.filter((plugin) => plugin.id === requestedChannel) + : plugins; + if (rawChannel !== undefined && !requestedChannel) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown channel: ${formatForLog(rawChannel)}`), + ); + return; + } const pluginMap = new Map( - plugins.map((plugin) => [plugin.id, plugin]), + selectedPlugins.map((plugin) => [plugin.id, plugin]), ); const statusWarnings: string[] = []; @@ -461,7 +475,7 @@ export const channelsHandlers: GatewayRequestHandlers = { return { accounts, defaultAccountId, defaultAccount, resolvedAccounts }; }; - const uiCatalog = buildChannelUiCatalog(plugins); + const uiCatalog = buildChannelUiCatalog(selectedPlugins); const payload: Record = { ts: Date.now(), channelOrder: uiCatalog.order, @@ -478,7 +492,7 @@ export const channelsHandlers: GatewayRequestHandlers = { const accountsMap = payload.channelAccounts as Record; const defaultAccountIdMap = payload.channelDefaultAccountId as Record; const { results: channelResults } = await runTasksWithConcurrency({ - tasks: plugins.map((plugin) => async () => { + tasks: selectedPlugins.map((plugin) => async () => { const { accounts, defaultAccountId, defaultAccount, resolvedAccounts } = await buildChannelAccounts(plugin.id); const fallbackAccount = @@ -509,7 +523,7 @@ export const channelsHandlers: GatewayRequestHandlers = { } return { pluginId: plugin.id, summary, accounts, defaultAccountId }; }), - limit: probe ? CHANNEL_STATUS_PROBE_CONCURRENCY : plugins.length || 1, + limit: probe ? CHANNEL_STATUS_PROBE_CONCURRENCY : selectedPlugins.length || 1, }); for (const result of channelResults) { if (result) {