Darochin's picture
Mirror OpenSkyNet workspace snapshot from Git HEAD
fc93158 verified
import { formatCliCommand } from "../../../cli/command-format.js";
import { detectBinary } from "../../../commands/onboard-helpers.js";
import { installSignalCli } from "../../../commands/signal-install.js";
import type { OpenClawConfig } from "../../../config/config.js";
import {
listSignalAccountIds,
resolveDefaultSignalAccountId,
resolveSignalAccount,
} from "../../../signal/accounts.js";
import { formatDocsLink } from "../../../terminal/links.js";
import { normalizeE164 } from "../../../utils.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
import * as onboardingHelpers from "./helpers.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 onboardingHelpers.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 };
});
}
async function promptSignalAllowFrom(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
return onboardingHelpers.promptParsedAllowFromForScopedChannel({
cfg: params.cfg,
channel: "signal",
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 }) => {
const resolved = resolveSignalAccount({ cfg, accountId });
return resolved.config.allowFrom ?? [];
},
});
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Signal",
channel,
policyKey: "channels.signal.dmPolicy",
allowFromKey: "channels.signal.allowFrom",
getCurrent: (cfg) => cfg.channels?.signal?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) =>
onboardingHelpers.setChannelDmPolicyWithAllowFrom({
cfg,
channel: "signal",
dmPolicy: policy,
}),
promptAllowFrom: promptSignalAllowFrom,
};
export const signalOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listSignalAccountIds(cfg).some(
(accountId) => resolveSignalAccount({ cfg, accountId }).configured,
);
const signalCliPath = cfg.channels?.signal?.cliPath ?? "signal-cli";
const signalCliDetected = await detectBinary(signalCliPath);
return {
channel,
configured,
statusLines: [
`Signal: ${configured ? "configured" : "needs setup"}`,
`signal-cli: ${signalCliDetected ? "found" : "missing"} (${signalCliPath})`,
],
selectionHint: signalCliDetected ? "signal-cli found" : "signal-cli missing",
quickstartScore: signalCliDetected ? 1 : 0,
};
},
configure: async ({
cfg,
runtime,
prompter,
accountOverrides,
shouldPromptAccountIds,
options,
}) => {
const defaultSignalAccountId = resolveDefaultSignalAccountId(cfg);
const signalAccountId = await onboardingHelpers.resolveAccountIdForConfigure({
cfg,
prompter,
label: "Signal",
accountOverride: accountOverrides.signal,
shouldPromptAccountIds,
listAccountIds: listSignalAccountIds,
defaultAccountId: defaultSignalAccountId,
});
let next = cfg;
const resolvedAccount = resolveSignalAccount({
cfg: next,
accountId: signalAccountId,
});
const accountConfig = resolvedAccount.config;
let resolvedCliPath = accountConfig.cliPath ?? "signal-cli";
let cliDetected = await detectBinary(resolvedCliPath);
if (options?.allowSignalInstall) {
const wantsInstall = await prompter.confirm({
message: cliDetected
? "signal-cli detected. Reinstall/update now?"
: "signal-cli not found. Install now?",
initialValue: !cliDetected,
});
if (wantsInstall) {
try {
const result = await installSignalCli(runtime);
if (result.ok && result.cliPath) {
cliDetected = true;
resolvedCliPath = result.cliPath;
await prompter.note(`Installed signal-cli at ${result.cliPath}`, "Signal");
} else if (!result.ok) {
await prompter.note(result.error ?? "signal-cli install failed.", "Signal");
}
} catch (err) {
await prompter.note(`signal-cli install failed: ${String(err)}`, "Signal");
}
}
}
if (!cliDetected) {
await prompter.note(
"signal-cli not found. Install it, then rerun this step or set channels.signal.cliPath.",
"Signal",
);
}
let account = accountConfig.account ?? "";
if (account) {
const normalizedExisting = normalizeSignalAccountInput(account);
if (!normalizedExisting) {
await prompter.note(
"Existing Signal account isn't a valid E.164 number. Please enter it again.",
"Signal",
);
account = "";
} else {
account = normalizedExisting;
const keep = await prompter.confirm({
message: `Signal account set (${account}). Keep it?`,
initialValue: true,
});
if (!keep) {
account = "";
}
}
}
if (!account) {
const rawAccount = String(
await prompter.text({
message: "Signal bot number (E.164)",
validate: (value) =>
normalizeSignalAccountInput(String(value ?? ""))
? undefined
: INVALID_SIGNAL_ACCOUNT_ERROR,
}),
);
account = normalizeSignalAccountInput(rawAccount) ?? "";
}
if (account) {
next = onboardingHelpers.patchChannelConfigForAccount({
cfg: next,
channel: "signal",
accountId: signalAccountId,
patch: {
account,
cliPath: resolvedCliPath ?? "signal-cli",
},
});
}
await prompter.note(
[
'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")}`,
].join("\n"),
"Signal next steps",
);
return { cfg: next, accountId: signalAccountId };
},
dmPolicy,
disable: (cfg) => onboardingHelpers.setOnboardingChannelEnabled(cfg, channel, false),
};