diff --git a/extensions/signal/src/channel.runtime.ts b/extensions/signal/src/channel.runtime.ts new file mode 100644 index 00000000000..0403246478f --- /dev/null +++ b/extensions/signal/src/channel.runtime.ts @@ -0,0 +1 @@ +export { signalSetupWizard } from "./setup-surface.js"; diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts index ccf635e60cf..8b2f0998ff9 100644 --- a/extensions/signal/src/channel.ts +++ b/extensions/signal/src/channel.ts @@ -28,7 +28,15 @@ import { } from "openclaw/plugin-sdk/signal"; import { resolveOutboundSendDep } from "../../../src/infra/outbound/send-deps.js"; import { getSignalRuntime } from "./runtime.js"; -import { signalSetupAdapter, signalSetupWizard } from "./setup-surface.js"; +import { createSignalSetupWizardProxy, signalSetupAdapter } from "./setup-core.js"; + +async function loadSignalChannelRuntime() { + return await import("./channel.runtime.js"); +} + +const signalSetupWizard = createSignalSetupWizardProxy(async () => ({ + signalSetupWizard: (await loadSignalChannelRuntime()).signalSetupWizard, +})); const signalMessageActions: ChannelMessageActionAdapter = { listActions: (ctx) => getSignalRuntime().channel.signal.messageActions?.listActions?.(ctx) ?? [], diff --git a/extensions/signal/src/setup-core.ts b/extensions/signal/src/setup-core.ts new file mode 100644 index 00000000000..2f46c4d4c4c --- /dev/null +++ b/extensions/signal/src/setup-core.ts @@ -0,0 +1,275 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + parseOnboardingEntriesAllowingWildcard, + promptParsedAllowFromForScopedChannel, + setChannelDmPolicyWithAllowFrom, + setOnboardingChannelEnabled, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import { formatCliCommand } from "../../../src/cli/command-format.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { normalizeE164 } from "../../../src/utils.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { + listSignalAccountIds, + resolveDefaultSignalAccountId, + resolveSignalAccount, +} from "./accounts.js"; + +const channel = "signal" as const; +const MIN_E164_DIGITS = 5; +const MAX_E164_DIGITS = 15; +const DIGITS_ONLY = /^\d+$/; +const INVALID_SIGNAL_ACCOUNT_ERROR = + "Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)"; + +export function normalizeSignalAccountInput(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + if (!trimmed) { + return null; + } + const normalized = normalizeE164(trimmed); + const digits = normalized.slice(1); + if (!DIGITS_ONLY.test(digits)) { + return null; + } + if (digits.length < MIN_E164_DIGITS || digits.length > MAX_E164_DIGITS) { + return null; + } + return `+${digits}`; +} + +function isUuidLike(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); +} + +export function parseSignalAllowFromEntries(raw: string): { entries: string[]; error?: string } { + return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { + if (entry.toLowerCase().startsWith("uuid:")) { + const id = entry.slice("uuid:".length).trim(); + if (!id) { + return { error: "Invalid uuid entry" }; + } + return { value: `uuid:${id}` }; + } + if (isUuidLike(entry)) { + return { value: `uuid:${entry}` }; + } + const normalized = normalizeSignalAccountInput(entry); + if (!normalized) { + return { error: `Invalid entry: ${entry}` }; + } + return { value: normalized }; + }); +} + +function buildSignalSetupPatch(input: { + signalNumber?: string; + cliPath?: string; + httpUrl?: string; + httpHost?: string; + httpPort?: string; +}) { + return { + ...(input.signalNumber ? { account: input.signalNumber } : {}), + ...(input.cliPath ? { cliPath: input.cliPath } : {}), + ...(input.httpUrl ? { httpUrl: input.httpUrl } : {}), + ...(input.httpHost ? { httpHost: input.httpHost } : {}), + ...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}), + }; +} + +async function promptSignalAllowFrom(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + return promptParsedAllowFromForScopedChannel({ + cfg: params.cfg, + channel, + accountId: params.accountId, + defaultAccountId: resolveDefaultSignalAccountId(params.cfg), + prompter: params.prompter, + noteTitle: "Signal allowlist", + noteLines: [ + "Allowlist Signal DMs by sender id.", + "Examples:", + "- +15555550123", + "- uuid:123e4567-e89b-12d3-a456-426614174000", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/signal", "signal")}`, + ], + message: "Signal allowFrom (E.164 or uuid)", + placeholder: "+15555550123, uuid:123e4567-e89b-12d3-a456-426614174000", + parseEntries: parseSignalAllowFromEntries, + getExistingAllowFrom: ({ cfg, accountId }) => + resolveSignalAccount({ cfg, accountId }).config.allowFrom ?? [], + }); +} + +export const signalSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ input }) => { + if ( + !input.signalNumber && + !input.httpUrl && + !input.httpHost && + !input.httpPort && + !input.cliPath + ) { + return "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...next, + channels: { + ...next.channels, + signal: { + ...next.channels?.signal, + enabled: true, + ...buildSignalSetupPatch(input), + }, + }, + }; + } + return { + ...next, + channels: { + ...next.channels, + signal: { + ...next.channels?.signal, + enabled: true, + accounts: { + ...next.channels?.signal?.accounts, + [accountId]: { + ...next.channels?.signal?.accounts?.[accountId], + enabled: true, + ...buildSignalSetupPatch(input), + }, + }, + }, + }, + }; + }, +}; + +export function createSignalSetupWizardProxy( + loadWizard: () => Promise<{ signalSetupWizard: ChannelSetupWizard }>, +) { + const signalDmPolicy: ChannelOnboardingDmPolicy = { + label: "Signal", + channel, + policyKey: "channels.signal.dmPolicy", + allowFromKey: "channels.signal.allowFrom", + getCurrent: (cfg: OpenClawConfig) => cfg.channels?.signal?.dmPolicy ?? "pairing", + setPolicy: (cfg: OpenClawConfig, policy) => + setChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), + promptAllowFrom: promptSignalAllowFrom, + }; + + return { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "signal-cli found", + unconfiguredHint: "signal-cli missing", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => + listSignalAccountIds(cfg).some( + (accountId) => resolveSignalAccount({ cfg, accountId }).configured, + ), + resolveStatusLines: async (params) => + (await loadWizard()).signalSetupWizard.status.resolveStatusLines?.(params) ?? [], + resolveSelectionHint: async (params) => + await (await loadWizard()).signalSetupWizard.status.resolveSelectionHint?.(params), + resolveQuickstartScore: async (params) => + await (await loadWizard()).signalSetupWizard.status.resolveQuickstartScore?.(params), + }, + prepare: async (params) => await (await loadWizard()).signalSetupWizard.prepare?.(params), + credentials: [], + textInputs: [ + { + inputKey: "cliPath", + message: "signal-cli path", + currentValue: ({ cfg, accountId, credentialValues }) => + (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? + resolveSignalAccount({ cfg, accountId }).config.cliPath ?? + "signal-cli", + initialValue: ({ cfg, accountId, credentialValues }) => + (typeof credentialValues.cliPath === "string" ? credentialValues.cliPath : undefined) ?? + resolveSignalAccount({ cfg, accountId }).config.cliPath ?? + "signal-cli", + shouldPrompt: async (params) => { + const input = (await loadWizard()).signalSetupWizard.textInputs?.find( + (entry) => entry.inputKey === "cliPath", + ); + return (await input?.shouldPrompt?.(params)) ?? false; + }, + confirmCurrentValue: false, + applyCurrentValue: true, + helpTitle: "Signal", + helpLines: [ + "signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.", + ], + }, + { + inputKey: "signalNumber", + message: "Signal bot number (E.164)", + currentValue: ({ cfg, accountId }) => + normalizeSignalAccountInput(resolveSignalAccount({ cfg, accountId }).config.account) ?? + undefined, + keepPrompt: (value) => `Signal account set (${value}). Keep it?`, + validate: ({ value }) => + normalizeSignalAccountInput(value) ? undefined : INVALID_SIGNAL_ACCOUNT_ERROR, + normalizeValue: ({ value }) => normalizeSignalAccountInput(value) ?? value, + }, + ], + completionNote: { + title: "Signal next steps", + lines: [ + 'Link device with: signal-cli link -n "OpenClaw"', + "Scan QR in Signal -> Linked Devices", + `Then run: ${formatCliCommand("openclaw gateway call channels.status --params '{\"probe\":true}'")}`, + `Docs: ${formatDocsLink("/signal", "signal")}`, + ], + }, + dmPolicy: signalDmPolicy, + disable: (cfg: OpenClawConfig) => setOnboardingChannelEnabled(cfg, channel, false), + } satisfies ChannelSetupWizard; +} diff --git a/extensions/signal/src/setup-surface.ts b/extensions/signal/src/setup-surface.ts index 6a7b7604450..51dbbd5625a 100644 --- a/extensions/signal/src/setup-surface.ts +++ b/extensions/signal/src/setup-surface.ts @@ -5,89 +5,29 @@ import { setChannelDmPolicyWithAllowFrom, setOnboardingChannelEnabled, } from "../../../src/channels/plugins/onboarding/helpers.js"; -import { - applyAccountNameToChannelSection, - migrateBaseNameToDefaultAccount, -} from "../../../src/channels/plugins/setup-helpers.js"; import { type ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; -import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; import { formatCliCommand } from "../../../src/cli/command-format.js"; import { detectBinary } from "../../../src/commands/onboard-helpers.js"; import { installSignalCli } from "../../../src/commands/signal-install.js"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; import { formatDocsLink } from "../../../src/terminal/links.js"; -import { normalizeE164 } from "../../../src/utils.js"; import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { listSignalAccountIds, resolveDefaultSignalAccountId, resolveSignalAccount, } from "./accounts.js"; +import { + normalizeSignalAccountInput, + parseSignalAllowFromEntries, + signalSetupAdapter, +} from "./setup-core.js"; const channel = "signal" as const; -const MIN_E164_DIGITS = 5; -const MAX_E164_DIGITS = 15; -const DIGITS_ONLY = /^\d+$/; const INVALID_SIGNAL_ACCOUNT_ERROR = "Invalid E.164 phone number (must start with + and country code, e.g. +15555550123)"; -export function normalizeSignalAccountInput(value: string | null | undefined): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - const normalized = normalizeE164(trimmed); - const digits = normalized.slice(1); - if (!DIGITS_ONLY.test(digits)) { - return null; - } - if (digits.length < MIN_E164_DIGITS || digits.length > MAX_E164_DIGITS) { - return null; - } - return `+${digits}`; -} - -function isUuidLike(value: string): boolean { - return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); -} - -export function parseSignalAllowFromEntries(raw: string): { entries: string[]; error?: string } { - return parseOnboardingEntriesAllowingWildcard(raw, (entry) => { - if (entry.toLowerCase().startsWith("uuid:")) { - const id = entry.slice("uuid:".length).trim(); - if (!id) { - return { error: "Invalid uuid entry" }; - } - return { value: `uuid:${id}` }; - } - if (isUuidLike(entry)) { - return { value: `uuid:${entry}` }; - } - const normalized = normalizeSignalAccountInput(entry); - if (!normalized) { - return { error: `Invalid entry: ${entry}` }; - } - return { value: normalized }; - }); -} - -function buildSignalSetupPatch(input: { - signalNumber?: string; - cliPath?: string; - httpUrl?: string; - httpHost?: string; - httpPort?: string; -}) { - return { - ...(input.signalNumber ? { account: input.signalNumber } : {}), - ...(input.cliPath ? { cliPath: input.cliPath } : {}), - ...(input.httpUrl ? { httpUrl: input.httpUrl } : {}), - ...(input.httpHost ? { httpHost: input.httpHost } : {}), - ...(input.httpPort ? { httpPort: Number(input.httpPort) } : {}), - }; -} - async function promptSignalAllowFrom(params: { cfg: OpenClawConfig; prompter: WizardPrompter; @@ -131,75 +71,6 @@ const signalDmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptSignalAllowFrom, }; -export const signalSetupAdapter: ChannelSetupAdapter = { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name, - }), - validateInput: ({ input }) => { - if ( - !input.signalNumber && - !input.httpUrl && - !input.httpHost && - !input.httpPort && - !input.cliPath - ) { - return "Signal requires --signal-number or --http-url/--http-host/--http-port/--cli-path."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg, - channelKey: channel, - accountId, - name: input.name, - }); - const next = - accountId !== DEFAULT_ACCOUNT_ID - ? migrateBaseNameToDefaultAccount({ - cfg: namedConfig, - channelKey: channel, - }) - : namedConfig; - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...next, - channels: { - ...next.channels, - signal: { - ...next.channels?.signal, - enabled: true, - ...buildSignalSetupPatch(input), - }, - }, - }; - } - return { - ...next, - channels: { - ...next.channels, - signal: { - ...next.channels?.signal, - enabled: true, - accounts: { - ...next.channels?.signal?.accounts, - [accountId]: { - ...next.channels?.signal?.accounts?.[accountId], - enabled: true, - ...buildSignalSetupPatch(input), - }, - }, - }, - }, - }; - }, -}; - export const signalSetupWizard: ChannelSetupWizard = { channel, status: { diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index a6044a0da84..04d03c56f8e 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -782,10 +782,8 @@ export { resolveSignalAccount, type ResolvedSignalAccount, } from "../../extensions/signal/src/accounts.js"; -export { - signalSetupAdapter, - signalSetupWizard, -} from "../../extensions/signal/src/setup-surface.js"; +export { signalSetupWizard } from "../../extensions/signal/src/setup-surface.js"; +export { signalSetupAdapter } from "../../extensions/signal/src/setup-core.js"; export { looksLikeSignalTargetId, normalizeSignalMessagingTarget, diff --git a/src/plugin-sdk/signal.ts b/src/plugin-sdk/signal.ts index 2eb0497c277..f57a046ab03 100644 --- a/src/plugin-sdk/signal.ts +++ b/src/plugin-sdk/signal.ts @@ -16,10 +16,8 @@ export { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, } from "../config/runtime-group-policy.js"; -export { - signalSetupAdapter, - signalSetupWizard, -} from "../../extensions/signal/src/setup-surface.js"; +export { signalSetupWizard } from "../../extensions/signal/src/setup-surface.js"; +export { signalSetupAdapter } from "../../extensions/signal/src/setup-core.js"; export { SignalConfigSchema } from "../config/zod-schema.providers-core.js"; export { normalizeE164 } from "../utils.js";