mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
feat(signal): link first account from setup QR
Co-authored-by: jesse-merhi <79823012+jesse-merhi@users.noreply.github.com>
This commit is contained in:
@@ -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 <e164>` for the bot phone number, plus `--http-host <host>` and `--http-port <port>` for the Signal daemon endpoint (default `127.0.0.1:8080`).
|
||||
|
||||
</Step>
|
||||
<Step title="Link or register the account">
|
||||
- **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).
|
||||
|
||||
</Step>
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
|
||||
@@ -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"] },
|
||||
);
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
|
||||
@@ -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<string, string | undefined> }) =>
|
||||
credentialValues[SIGNAL_LINK_COMPLETED_CREDENTIAL] !== "true",
|
||||
};
|
||||
|
||||
const signalSetupAdapterBase = createPatchedAccountSetupAdapter<SignalSetupInput>({
|
||||
|
||||
@@ -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<typeof import("openclaw/plugin-sdk/setup-tools")>()),
|
||||
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<typeof import("./daemon-lifecycle.js")>()),
|
||||
waitForSignalDaemonReady: mocks.waitReady,
|
||||
}));
|
||||
vi.mock("./install-signal-cli.js", () => ({ installSignalCli: mocks.installSignalCli }));
|
||||
|
||||
type PrepareParams = Parameters<NonNullable<typeof signalSetupWizard.prepare>>[0];
|
||||
|
||||
function createPrompter(
|
||||
params: {
|
||||
confirm?: boolean;
|
||||
qrCode?: (value: Parameters<NonNullable<WizardPrompter["qrCode"]>>[0]) => Promise<unknown>;
|
||||
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<NonNullable<WizardPrompter["qrCode"]>>[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<never>(() => {}),
|
||||
isExited: () => false,
|
||||
stop: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareSignal(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
prompter?: WizardPrompter;
|
||||
signal?: AbortSignal;
|
||||
includeSignal?: boolean;
|
||||
beforePersistentEffect?: () => Promise<void>;
|
||||
}) {
|
||||
const prepare = signalSetupWizard.prepare;
|
||||
if (!prepare) {
|
||||
throw new Error("expected Signal setup prepare hook");
|
||||
}
|
||||
const options: NonNullable<PrepareParams["options"]> = {
|
||||
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<never>(() => {}));
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<string | undefined> {
|
||||
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<string, string> = {};
|
||||
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: [
|
||||
|
||||
Reference in New Issue
Block a user