From bfdde201ee23a259de1a4f4b59b8283f2c27bac0 Mon Sep 17 00:00:00 2001 From: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:51:57 +0000 Subject: [PATCH] feat(signal): link first account from setup QR Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com> --- docs/channels/signal.md | 14 +- extensions/signal/src/daemon-lifecycle.ts | 34 +++ extensions/signal/src/daemon.test.ts | 3 + extensions/signal/src/monitor.ts | 35 +-- extensions/signal/src/setup-core.ts | 6 + extensions/signal/src/setup-surface.test.ts | 286 ++++++++++++++++++++ extensions/signal/src/setup-surface.ts | 241 +++++++++++++++-- 7 files changed, 561 insertions(+), 58 deletions(-) create mode 100644 extensions/signal/src/setup-surface.test.ts diff --git a/docs/channels/signal.md b/docs/channels/signal.md index f91a9fa626cb..85cf47a0c359 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -37,13 +37,16 @@ Bare plugin specs try ClawHub first, then npm fallback. Force a source with `ope ```bash openclaw channels add ``` - The wizard detects whether `signal-cli` is on `PATH` and, when missing, offers to install it: downloads the official native GraalVM build on Linux x86-64, or installs via Homebrew on macOS and other architectures. It then prompts for the bot number and `signal-cli` path. + The wizard detects whether `signal-cli` is on `PATH` and, when missing, offers to install it: downloads the official native GraalVM build on Linux x86-64, or installs via Homebrew on macOS and other architectures. + + A hosted setup client with QR support can link the first managed-native Signal account directly. Scan the displayed QR in Signal; setup records the linked number without another number prompt. CLI setup, additional accounts, relinking, external daemons, and containers keep the manual flow below. For non-interactive setup, `openclaw channels add --channel signal` also accepts `--signal-number ` for the bot phone number, plus `--http-host ` and `--http-port ` for the Signal daemon endpoint (default `127.0.0.1:8080`). - - **QR link (fastest):** `signal-cli link -n "OpenClaw"`, then scan with Signal. See [Path A](#setup-path-a-link-existing-signal-account-qr). + - **Hosted QR link (fastest):** if setup displayed a QR and confirmed the account, continue to verification. + - **Manual QR link:** otherwise run `signal-cli link -n "OpenClaw"`, then scan with Signal. See [Path A](#setup-path-a-link-existing-signal-account-qr). - **SMS registration:** dedicated number with captcha + SMS verification. See [Path B](#setup-path-b-register-dedicated-bot-number-sms-linux). @@ -91,9 +94,10 @@ Multi-account support: use `channels.signal.accounts` with per-account config an ## Setup path A: link existing Signal account (QR) -1. Install `signal-cli` (JVM or native build), or let `openclaw channels add` install it for you. -2. Link a bot account: `signal-cli link -n "OpenClaw"`, then scan the QR in Signal. -3. Configure Signal and start the gateway. +1. Install `signal-cli` (JVM or native build), or let setup install it for you. +2. In a hosted setup client that displays the first-account QR, scan it in Signal under **Settings > Linked devices**, then continue. +3. For CLI setup, additional accounts, relinking, external daemons, or containers, run `signal-cli link -n "OpenClaw"` and scan the terminal QR instead. +4. Configure Signal and start the gateway. ## Setup path B: register dedicated bot number (SMS, Linux) diff --git a/extensions/signal/src/daemon-lifecycle.ts b/extensions/signal/src/daemon-lifecycle.ts index 657c57f8199c..e3061893e5bb 100644 --- a/extensions/signal/src/daemon-lifecycle.ts +++ b/extensions/signal/src/daemon-lifecycle.ts @@ -1,5 +1,39 @@ +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runtime"; +import { signalCheck } from "./client-adapter.js"; import { formatSignalDaemonExit, type SignalDaemonHandle } from "./daemon.js"; +export async function waitForSignalDaemonReady(params: { + baseUrl: string; + abortSignal?: AbortSignal; + timeoutMs: number; + logAfterMs: number; + logIntervalMs?: number; + runtime: RuntimeEnv; + waitForTransportReadyFn?: typeof waitForTransportReady; +}): Promise { + const waitForTransportReadyFn = params.waitForTransportReadyFn ?? waitForTransportReady; + await waitForTransportReadyFn({ + label: "signal daemon", + timeoutMs: params.timeoutMs, + logAfterMs: params.logAfterMs, + logIntervalMs: params.logIntervalMs, + pollIntervalMs: 150, + abortSignal: params.abortSignal, + runtime: params.runtime, + check: async () => { + const res = await signalCheck(params.baseUrl, 1000); + if (res.ok) { + return { ok: true }; + } + return { + ok: false, + error: res.error ?? (res.status ? `HTTP ${res.status}` : "unreachable"), + }; + }, + }); +} + export function createSignalDaemonLifecycle(params: { abortSignal?: AbortSignal }) { let daemonHandle: SignalDaemonHandle | null = null; let daemonStopRequested = false; diff --git a/extensions/signal/src/daemon.test.ts b/extensions/signal/src/daemon.test.ts index 9b2f99816403..f903ba76e5de 100644 --- a/extensions/signal/src/daemon.test.ts +++ b/extensions/signal/src/daemon.test.ts @@ -48,6 +48,7 @@ describe("spawnSignalDaemon", () => { configPath: "~/.openclaw/signal-cli", httpHost: "127.0.0.1", httpPort: 8080, + receiveMode: "manual", }); expect(spawnMock).toHaveBeenCalledWith( @@ -59,6 +60,8 @@ describe("spawnSignalDaemon", () => { "--http", "127.0.0.1:8080", "--no-receive-stdout", + "--receive-mode", + "manual", ], { stdio: ["ignore", "pipe", "pipe"] }, ); diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index 4c114aa6240d..2dad08e03372 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -46,9 +46,9 @@ import { waitForTransportReady } from "openclaw/plugin-sdk/transport-ready-runti 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 { signalRpcRequest } from "./client-adapter.js"; import type { SignalTransportKind } from "./client-adapter.js"; -import { createSignalDaemonLifecycle } from "./daemon-lifecycle.js"; +import { createSignalDaemonLifecycle, waitForSignalDaemonReady } from "./daemon-lifecycle.js"; import { spawnSignalDaemon, type SignalDaemonHandle } from "./daemon.js"; import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js"; import { createSignalEventHandler } from "./monitor/event-handler.js"; @@ -194,37 +194,6 @@ function buildSignalReactionSystemEventText(params: { return params.groupLabel ? `${withTarget} in ${params.groupLabel}` : withTarget; } -async function waitForSignalDaemonReady(params: { - baseUrl: string; - abortSignal?: AbortSignal; - timeoutMs: number; - logAfterMs: number; - logIntervalMs?: number; - runtime: RuntimeEnv; - waitForTransportReadyFn?: typeof waitForTransportReady; -}): Promise { - const waitForTransportReadyFn = params.waitForTransportReadyFn ?? waitForTransportReady; - await waitForTransportReadyFn({ - label: "signal daemon", - timeoutMs: params.timeoutMs, - logAfterMs: params.logAfterMs, - logIntervalMs: params.logIntervalMs, - pollIntervalMs: 150, - abortSignal: params.abortSignal, - runtime: params.runtime, - check: async () => { - const res = await signalCheck(params.baseUrl, 1000); - if (res.ok) { - return { ok: true }; - } - return { - ok: false, - error: res.error ?? (res.status ? `HTTP ${res.status}` : "unreachable"), - }; - }, - }); -} - const SIGNAL_ATTACHMENT_RPC_RESPONSE_HEADROOM_BYTES = 64 * 1024; const SIGNAL_BASE64_OVERHEAD_NUMERATOR = 4; const SIGNAL_BASE64_OVERHEAD_DENOMINATOR = 3; diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts index faac51661913..db26754485ed 100644 --- a/extensions/signal/src/setup-core.ts +++ b/extensions/signal/src/setup-core.ts @@ -40,6 +40,7 @@ import { normalizeSignalTransportHost, normalizeSignalTransportUrl } from "./tra const t = createSetupTranslator(); const channel = "signal" as const; +export const SIGNAL_LINK_COMPLETED_CREDENTIAL = "signalLinkCompleted"; const signalSetupFields = { signalNumber: { @@ -297,6 +298,9 @@ export const signalNumberTextInput: ChannelSetupWizardTextInput = { validate: ({ value }) => normalizeSignalAccountInput(value) ? undefined : INVALID_SIGNAL_ACCOUNT_ERROR, normalizeValue: ({ value }) => normalizeSignalAccountInput(value) ?? value, + shouldPrompt: ({ credentialValues }) => + credentialValues[SIGNAL_LINK_COMPLETED_CREDENTIAL] !== "true", + applyCurrentValue: true, }; export const signalCompletionNote = { @@ -307,6 +311,8 @@ export const signalCompletionNote = { `Then run: ${formatCliCommand("openclaw gateway call channels.status --params '{\"probe\":true}'")}`, `Docs: ${formatDocsLink("/signal", "signal")}`, ], + shouldShow: ({ credentialValues }: { credentialValues: Record }) => + credentialValues[SIGNAL_LINK_COMPLETED_CREDENTIAL] !== "true", }; const signalSetupAdapterBase = createPatchedAccountSetupAdapter({ diff --git a/extensions/signal/src/setup-surface.test.ts b/extensions/signal/src/setup-surface.test.ts new file mode 100644 index 000000000000..09e3638517c4 --- /dev/null +++ b/extensions/signal/src/setup-surface.test.ts @@ -0,0 +1,286 @@ +// Signal setup tests cover hosted first-account linking and manual fallbacks. +import type { OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk/setup"; +import { WizardCancelledError } from "openclaw/plugin-sdk/setup"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { SIGNAL_LINK_COMPLETED_CREDENTIAL } from "./setup-core.js"; +import { signalSetupWizard } from "./setup-surface.js"; + +const mocks = vi.hoisted(() => ({ + detectBinary: vi.fn(), + installSignalCli: vi.fn(), + rpc: vi.fn(), + spawnDaemon: vi.fn(), + waitReady: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/setup-tools", async (importOriginal) => ({ + ...(await importOriginal()), + detectBinary: mocks.detectBinary, +})); +vi.mock("./client.js", () => ({ signalRpcRequest: mocks.rpc })); +vi.mock("./daemon.js", () => ({ spawnSignalDaemon: mocks.spawnDaemon })); +vi.mock("./daemon-lifecycle.js", async (importOriginal) => ({ + ...(await importOriginal()), + waitForSignalDaemonReady: mocks.waitReady, +})); +vi.mock("./install-signal-cli.js", () => ({ installSignalCli: mocks.installSignalCli })); + +type PrepareParams = Parameters>[0]; + +function createPrompter( + params: { + confirm?: boolean; + qrCode?: (value: Parameters>[0]) => Promise; + selectedAccount?: string; + } = {}, +) { + const note = vi.fn(async () => {}); + const select = vi.fn(async () => params.selectedAccount ?? "+15555550123"); + const qrCode = params.qrCode + ? vi.fn(params.qrCode) + : vi.fn(async (value: Parameters>[0]) => value.settled); + return { + note, + qrCode, + select, + prompter: { + confirm: vi.fn(async () => params.confirm ?? false), + note, + qrCode, + select, + } as unknown as WizardPrompter, + }; +} + +function createDaemonHandle() { + return { + exited: new Promise(() => {}), + isExited: () => false, + stop: vi.fn(async () => {}), + }; +} + +async function prepareSignal(params: { + cfg?: OpenClawConfig; + prompter?: WizardPrompter; + signal?: AbortSignal; + includeSignal?: boolean; + beforePersistentEffect?: () => Promise; +}) { + const prepare = signalSetupWizard.prepare; + if (!prepare) { + throw new Error("expected Signal setup prepare hook"); + } + const options: NonNullable = { + allowSignalInstall: true, + ...(params.includeSignal === false + ? {} + : { signal: params.signal ?? new AbortController().signal }), + ...(params.beforePersistentEffect + ? { beforePersistentEffect: params.beforePersistentEffect } + : {}), + }; + return await prepare({ + cfg: params.cfg ?? {}, + accountId: "default", + credentialValues: {}, + runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, + prompter: params.prompter ?? createPrompter().prompter, + options, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.detectBinary.mockResolvedValue(true); + mocks.installSignalCli.mockResolvedValue({ ok: true, cliPath: "/tools/signal-cli" }); + mocks.spawnDaemon.mockImplementation(() => createDaemonHandle()); + mocks.waitReady.mockResolvedValue(undefined); +}); + +describe("Signal hosted setup linking", () => { + it("links the first managed-native account through one owned multi-account daemon", async () => { + const events: string[] = []; + const deviceLinkUri = "sgnl://linkdevice?uuid=test&pub_key=test"; + mocks.rpc.mockImplementation(async (method: string) => { + events.push(method); + if (method === "listAccounts") { + return []; + } + if (method === "startLink") { + return { deviceLinkUri }; + } + if (method === "finishLink") { + return { number: "+15555550123" }; + } + throw new Error(`unexpected method ${method}`); + }); + const prompt = createPrompter({ + qrCode: async (value) => { + events.push("qrCode"); + return await value.settled; + }, + }); + + const result = await prepareSignal({ + prompter: prompt.prompter, + }); + + expect(mocks.spawnDaemon).toHaveBeenCalledWith({ + cliPath: "signal-cli", + httpHost: "127.0.0.1", + httpPort: 8080, + receiveMode: "manual", + }); + expect(mocks.spawnDaemon.mock.calls[0]?.[0]).not.toHaveProperty("account"); + expect(events).toEqual(["listAccounts", "startLink", "finishLink", "qrCode"]); + expect(prompt.qrCode).toHaveBeenCalledWith({ + title: "Link Signal", + message: "In Signal, open Settings → Linked devices and scan this QR code.", + text: deviceLinkUri, + expiresInMs: 120_000, + settled: expect.any(Promise), + }); + expect(result?.credentialValues).toEqual({ + signalNumber: "+15555550123", + [SIGNAL_LINK_COMPLETED_CREDENTIAL]: "true", + }); + expect(mocks.spawnDaemon.mock.results[0]?.value.stop).toHaveBeenCalledOnce(); + + const numberInput = signalSetupWizard.textInputs?.find( + (input) => input.inputKey === "signalNumber", + ); + expect(numberInput?.applyCurrentValue).toBe(true); + expect( + await numberInput?.shouldPrompt?.({ + cfg: {}, + accountId: "default", + credentialValues: result?.credentialValues ?? {}, + currentValue: "+15555550123", + }), + ).toBe(false); + expect( + await signalSetupWizard.completionNote?.shouldShow?.({ + cfg: {}, + accountId: "default", + credentialValues: result?.credentialValues ?? {}, + }), + ).toBe(false); + }); + + it("reuses a selected local signal-cli account without starting another link", async () => { + mocks.rpc.mockResolvedValueOnce([{ number: "+15555550125" }, { number: "+15555550123" }]); + const prompt = createPrompter({ selectedAccount: "+15555550125" }); + + const result = await prepareSignal({ prompter: prompt.prompter }); + + expect(prompt.select).toHaveBeenCalledWith({ + message: "Choose the Signal account for OpenClaw", + options: [ + { label: "+15555550123", value: "+15555550123" }, + { label: "+15555550125", value: "+15555550125" }, + ], + }); + expect(mocks.rpc).toHaveBeenCalledTimes(1); + expect(prompt.qrCode).not.toHaveBeenCalled(); + expect(result?.credentialValues?.signalNumber).toBe("+15555550125"); + }); + + it.each([ + { label: "without QR support", includeSignal: true, includeQr: false, cfg: {} }, + { label: "without hosted cancellation", includeSignal: false, includeQr: true, cfg: {} }, + { + label: "for an already configured account", + includeSignal: true, + includeQr: true, + cfg: { channels: { signal: { account: "+15555550123" } } } as OpenClawConfig, + }, + { + label: "for an external daemon", + includeSignal: true, + includeQr: true, + cfg: { + channels: { + signal: { + transport: { kind: "external-native", url: "http://127.0.0.1:8080" }, + }, + }, + } as OpenClawConfig, + }, + ])("keeps the manual setup flow $label", async ({ includeSignal, includeQr, cfg }) => { + const prompt = createPrompter(); + const prompter = includeQr + ? prompt.prompter + : ({ ...prompt.prompter, qrCode: undefined } as WizardPrompter); + + const result = await prepareSignal({ + cfg, + prompter, + includeSignal, + }); + + expect(mocks.spawnDaemon).not.toHaveBeenCalled(); + expect(result?.credentialValues?.signalNumber).toBeUndefined(); + expect( + await signalSetupWizard.completionNote?.shouldShow?.({ + cfg, + accountId: "default", + credentialValues: result?.credentialValues ?? {}, + }), + ).toBe(true); + }); + + it.each([ + { + label: "invalid start URI", + responses: [[], { deviceLinkUri: "https://example.invalid/private-token" }], + }, + { + label: "invalid finish number", + responses: [ + [], + { deviceLinkUri: "sgnl://linkdevice?uuid=private-token&pub_key=test" }, + { number: "private-number" }, + ], + }, + ])("falls back without exposing dependency data for an $label", async ({ responses }) => { + for (const response of responses) { + mocks.rpc.mockResolvedValueOnce(response); + } + const prompt = createPrompter(); + + const result = await prepareSignal({ prompter: prompt.prompter }); + + const notes = prompt.note.mock.calls.flat().map(String).join("\n"); + expect(result).toBeUndefined(); + expect(notes).toContain("Automatic Signal linking could not complete"); + expect(notes).not.toContain("private-token"); + expect(notes).not.toContain("private-number"); + expect(mocks.spawnDaemon.mock.results[0]?.value.stop).toHaveBeenCalledOnce(); + }); + + it("aborts linking and reaps its daemon without showing a dependency failure", async () => { + const controller = new AbortController(); + mocks.rpc + .mockResolvedValueOnce([]) + .mockResolvedValueOnce({ deviceLinkUri: "sgnl://linkdevice?uuid=test&pub_key=test" }) + .mockReturnValueOnce(new Promise(() => {})); + const prompt = createPrompter({ + qrCode: async () => { + controller.abort(new WizardCancelledError()); + throw new WizardCancelledError(); + }, + }); + + await expect( + prepareSignal({ + prompter: prompt.prompter, + signal: controller.signal, + beforePersistentEffect: vi.fn(async () => {}), + }), + ).rejects.toBeInstanceOf(WizardCancelledError); + + expect(prompt.note).not.toHaveBeenCalled(); + expect(mocks.spawnDaemon.mock.results[0]?.value.stop).toHaveBeenCalledOnce(); + }); +}); diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index 315c6de3bce8..161825dbf270 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -1,15 +1,25 @@ +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; // Signal plugin module implements setup surface behavior. import { createSetupTranslator, createDetectedBinaryStatus, setSetupChannelEnabled, type ChannelSetupWizard, + type OpenClawConfig, + type WizardPrompter, + WizardCancelledError, } from "openclaw/plugin-sdk/setup"; -import { detectBinary } from "openclaw/plugin-sdk/setup-tools"; +import { detectBinary, formatCliCommand } from "openclaw/plugin-sdk/setup-tools"; +import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; import { listSignalAccountIds, resolveSignalAccount } from "./accounts.js"; +import { signalRpcRequest } from "./client.js"; +import { createSignalDaemonLifecycle, waitForSignalDaemonReady } from "./daemon-lifecycle.js"; +import { spawnSignalDaemon } from "./daemon.js"; import { installSignalCli } from "./install-signal-cli.js"; import { createSignalCliPathTextInput, + normalizeSignalAccountInput, + SIGNAL_LINK_COMPLETED_CREDENTIAL, signalCompletionNote, signalDmPolicy, signalNumberTextInput, @@ -18,6 +28,9 @@ import { const t = createSetupTranslator(); const channel = "signal" as const; +const SIGNAL_LINK_URI_MAX_LENGTH = 4096; +const SIGNAL_LINK_RPC_MAX_BYTES = 16 * 1024; +const SIGNAL_LINK_EXPIRES_IN_MS = 120_000; const configuredLabel = t("wizard.channels.statusConfigured"); const unconfiguredLabel = t("wizard.channels.statusNeedsSetup"); const managedStatus = createDetectedBinaryStatus({ @@ -43,6 +56,173 @@ const managedStatus = createDetectedBinaryStatus({ detectBinary, }); +function parseSignalAccounts(value: unknown): string[] { + if (!Array.isArray(value) || value.length > 100) { + throw new Error("invalid Signal account list"); + } + return [ + ...new Set( + value.map((entry) => { + const rawNumber = isRecord(entry) ? entry.number : undefined; + const number = + typeof rawNumber === "string" ? normalizeSignalAccountInput(rawNumber) : null; + if (!number) { + throw new Error("invalid Signal account"); + } + return number; + }), + ), + ].toSorted(); +} + +function parseSignalLinkUri(value: unknown): string { + const rawUri = isRecord(value) ? value.deviceLinkUri : undefined; + const uri = typeof rawUri === "string" ? rawUri.trim() : ""; + if (!uri || uri !== rawUri || uri.length > SIGNAL_LINK_URI_MAX_LENGTH) { + throw new Error("invalid Signal device link URI"); + } + const parsed = new URL(uri); + if (parsed.protocol !== "sgnl:" || parsed.hostname !== "linkdevice") { + throw new Error("invalid Signal device link URI"); + } + return uri; +} + +function parseLinkedSignalNumber(value: unknown): string { + const rawNumber = isRecord(value) ? value.number : undefined; + const number = typeof rawNumber === "string" ? normalizeSignalAccountInput(rawNumber) : null; + if (!number) { + throw new Error("invalid linked Signal account"); + } + return number; +} + +async function noteSignalLinkFallback(prompter: WizardPrompter) { + await prompter.note( + [ + "Automatic Signal linking could not complete. Continue with the account number, then link it manually:", + formatCliCommand('signal-cli link -n "OpenClaw"'), + ].join("\n"), + "Signal", + ); +} + +async function prepareManagedSignalLink(params: { + cfg: OpenClawConfig; + accountId: string; + runtime: RuntimeEnv; + prompter: WizardPrompter; + options?: { signal?: AbortSignal }; + cliPath: string; +}): Promise { + const signal = params.options?.signal; + if ( + !signal || + !params.prompter.qrCode || + listSignalAccountIds(params.cfg).some( + (accountId) => resolveSignalAccount({ cfg: params.cfg, accountId }).configured, + ) + ) { + return undefined; + } + const transport = resolveSignalAccount({ + cfg: params.cfg, + accountId: params.accountId, + }).transport; + if (transport.kind !== "managed-native") { + return undefined; + } + + signal.throwIfAborted(); + const lifecycle = createSignalDaemonLifecycle({ abortSignal: signal }); + const onAbort = () => void lifecycle.stop(); + signal.addEventListener("abort", onAbort, { once: true }); + try { + let accounts: string[]; + let deviceLinkUri: string | undefined; + try { + const daemon = spawnSignalDaemon({ + cliPath: params.cliPath, + ...(transport.configPath ? { configPath: transport.configPath } : {}), + httpHost: transport.httpHost, + httpPort: transport.httpPort, + receiveMode: "manual", + }); + lifecycle.attach(daemon); + await waitForSignalDaemonReady({ + baseUrl: transport.baseUrl, + abortSignal: lifecycle.abortSignal, + timeoutMs: transport.startupTimeoutMs, + logAfterMs: transport.startupTimeoutMs, + runtime: { ...params.runtime, log: () => {}, error: () => {} }, + }); + accounts = parseSignalAccounts( + await signalRpcRequest("listAccounts", undefined, { + baseUrl: transport.baseUrl, + maxResponseBytes: SIGNAL_LINK_RPC_MAX_BYTES, + }), + ); + if (accounts.length === 0) { + deviceLinkUri = parseSignalLinkUri( + await signalRpcRequest("startLink", undefined, { + baseUrl: transport.baseUrl, + timeoutMs: 35_000, + maxResponseBytes: SIGNAL_LINK_RPC_MAX_BYTES, + }), + ); + } + } catch { + if (signal.aborted) { + throw new WizardCancelledError(); + } + await noteSignalLinkFallback(params.prompter); + return undefined; + } + + if (accounts.length > 0) { + return accounts.length === 1 + ? accounts[0] + : await params.prompter.select({ + message: "Choose the Signal account for OpenClaw", + options: accounts.map((number) => ({ value: number, label: number })), + }); + } + if (!deviceLinkUri) { + await noteSignalLinkFallback(params.prompter); + return undefined; + } + + signal.throwIfAborted(); + try { + const settled = signalRpcRequest( + "finishLink", + { deviceLinkUri, deviceName: "OpenClaw" }, + { + baseUrl: transport.baseUrl, + timeoutMs: SIGNAL_LINK_EXPIRES_IN_MS + 5_000, + maxResponseBytes: SIGNAL_LINK_RPC_MAX_BYTES, + }, + ).then(parseLinkedSignalNumber); + return await params.prompter.qrCode({ + title: "Link Signal", + message: "In Signal, open Settings → Linked devices and scan this QR code.", + text: deviceLinkUri, + expiresInMs: SIGNAL_LINK_EXPIRES_IN_MS, + settled, + }); + } catch { + if (signal.aborted) { + throw new WizardCancelledError(); + } + await noteSignalLinkFallback(params.prompter); + return undefined; + } + } finally { + signal.removeEventListener("abort", onAbort); + await lifecycle.stop(); + } +} + export const signalSetupWizard: ChannelSetupWizard = { channel, status: { @@ -74,36 +254,57 @@ export const signalSetupWizard: ChannelSetupWizard = { if (transport.kind !== "managed-native") { return undefined; } - const currentCliPath = + let cliPath = (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? (transport.kind === "managed-native" ? transport.cliPath : undefined) ?? "signal-cli"; - const cliDetected = await detectBinary(currentCliPath); + const cliDetected = await detectBinary(cliPath); const wantsInstall = await prompter.confirm({ message: cliDetected ? t("wizard.signal.reinstallPrompt") : t("wizard.signal.installPrompt"), initialValue: !cliDetected, }); - if (!wantsInstall) { + if (!wantsInstall && !cliDetected) { return undefined; } - try { - await options?.beforePersistentEffect?.(); - const result = await installSignalCli(runtime); - if (result.ok && result.cliPath) { - await prompter.note(`Installed signal-cli at ${result.cliPath}`, "Signal"); - return { - credentialValues: { - cliPath: result.cliPath, - }, - }; + const preparedCredentialValues: Record = {}; + if (wantsInstall) { + try { + await options?.beforePersistentEffect?.(); + const result = await installSignalCli(runtime); + if (result.ok && result.cliPath) { + cliPath = result.cliPath; + preparedCredentialValues.cliPath = cliPath; + await prompter.note(`Installed signal-cli at ${cliPath}`, "Signal"); + } else if (!result.ok) { + await prompter.note( + "signal-cli installation failed. Install it manually and retry setup.", + "Signal", + ); + return undefined; + } + } catch { + await prompter.note( + "signal-cli installation failed. Install it manually and retry setup.", + "Signal", + ); + return undefined; } - if (!result.ok) { - await prompter.note(result.error ?? "signal-cli install failed.", "Signal"); - } - } catch (error) { - await prompter.note(`signal-cli install failed: ${String(error)}`, "Signal"); } - return undefined; + const linkedNumber = await prepareManagedSignalLink({ + cfg, + accountId, + runtime, + prompter, + options, + cliPath, + }); + if (linkedNumber) { + preparedCredentialValues.signalNumber = linkedNumber; + preparedCredentialValues[SIGNAL_LINK_COMPLETED_CREDENTIAL] = "true"; + } + return Object.keys(preparedCredentialValues).length > 0 + ? { credentialValues: preparedCredentialValues } + : undefined; }, credentials: [], textInputs: [