openclaw-vainplex/src/channels/plugins/onboarding/signal.ts
2026-01-19 00:34:26 +00:00

302 lines
9.2 KiB
TypeScript

import { detectBinary } from "../../../commands/onboard-helpers.js";
import { installSignalCli } from "../../../commands/signal-install.js";
import type { ClawdbotConfig } from "../../../config/config.js";
import type { DmPolicy } from "../../../config/types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.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 { addWildcardAllowFrom, promptAccountId } from "./helpers.js";
const channel = "signal" as const;
function setSignalDmPolicy(cfg: ClawdbotConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.signal?.allowFrom) : undefined;
return {
...cfg,
channels: {
...cfg.channels,
signal: {
...cfg.channels?.signal,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
function setSignalAllowFrom(
cfg: ClawdbotConfig,
accountId: string,
allowFrom: string[],
): ClawdbotConfig {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
signal: {
...cfg.channels?.signal,
allowFrom,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
signal: {
...cfg.channels?.signal,
accounts: {
...cfg.channels?.signal?.accounts,
[accountId]: {
...cfg.channels?.signal?.accounts?.[accountId],
allowFrom,
},
},
},
},
};
}
function parseSignalAllowFromInput(raw: string): string[] {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
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);
}
async function promptSignalAllowFrom(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<ClawdbotConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultSignalAccountId(params.cfg);
const resolved = resolveSignalAccount({ cfg: params.cfg, accountId });
const existing = resolved.config.allowFrom ?? [];
await params.prompter.note(
[
"Allowlist Signal DMs by sender id.",
"Examples:",
"- +15555550123",
"- uuid:123e4567-e89b-12d3-a456-426614174000",
"Multiple entries: comma-separated.",
`Docs: ${formatDocsLink("/signal", "signal")}`,
].join("\n"),
"Signal allowlist",
);
const entry = await params.prompter.text({
message: "Signal allowFrom (E.164 or uuid)",
placeholder: "+15555550123, uuid:123e4567-e89b-12d3-a456-426614174000",
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
const parts = parseSignalAllowFromInput(raw);
for (const part of parts) {
if (part === "*") continue;
if (part.toLowerCase().startsWith("uuid:")) {
if (!part.slice("uuid:".length).trim()) return "Invalid uuid entry";
continue;
}
if (isUuidLike(part)) continue;
if (!normalizeE164(part)) return `Invalid entry: ${part}`;
}
return undefined;
},
});
const parts = parseSignalAllowFromInput(String(entry));
const normalized = parts
.map((part) => {
if (part === "*") return "*";
if (part.toLowerCase().startsWith("uuid:")) return `uuid:${part.slice(5).trim()}`;
if (isUuidLike(part)) return `uuid:${part}`;
return normalizeE164(part);
})
.filter(Boolean);
const unique = [...new Set(normalized)];
return setSignalAllowFrom(params.cfg, accountId, unique);
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Signal",
channel,
policyKey: "channels.signal.dmPolicy",
allowFromKey: "channels.signal.allowFrom",
getCurrent: (cfg) => cfg.channels?.signal?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setSignalDmPolicy(cfg, 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 signalOverride = accountOverrides.signal?.trim();
const defaultSignalAccountId = resolveDefaultSignalAccountId(cfg);
let signalAccountId = signalOverride
? normalizeAccountId(signalOverride)
: defaultSignalAccountId;
if (shouldPromptAccountIds && !signalOverride) {
signalAccountId = await promptAccountId({
cfg,
prompter,
label: "Signal",
currentId: signalAccountId,
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 keep = await prompter.confirm({
message: `Signal account set (${account}). Keep it?`,
initialValue: true,
});
if (!keep) account = "";
}
if (!account) {
account = String(
await prompter.text({
message: "Signal bot number (E.164)",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
if (account) {
if (signalAccountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
signal: {
...next.channels?.signal,
enabled: true,
account,
cliPath: resolvedCliPath ?? "signal-cli",
},
},
};
} else {
next = {
...next,
channels: {
...next.channels,
signal: {
...next.channels?.signal,
enabled: true,
accounts: {
...next.channels?.signal?.accounts,
[signalAccountId]: {
...next.channels?.signal?.accounts?.[signalAccountId],
enabled: next.channels?.signal?.accounts?.[signalAccountId]?.enabled ?? true,
account,
cliPath: resolvedCliPath ?? "signal-cli",
},
},
},
},
};
}
}
await prompter.note(
[
'Link device with: signal-cli link -n "Clawdbot"',
"Scan QR in Signal → Linked Devices",
"Then run: clawdbot gateway call channels.status --params '{\"probe\":true}'",
`Docs: ${formatDocsLink("/signal", "signal")}`,
].join("\n"),
"Signal next steps",
);
return { cfg: next, accountId: signalAccountId };
},
dmPolicy,
disable: (cfg) => ({
...cfg,
channels: {
...cfg.channels,
signal: { ...cfg.channels?.signal, enabled: false },
},
}),
};