refactor(onboarding): dedupe channel allowlist flows

This commit is contained in:
Peter Steinberger
2026-02-22 11:28:42 +00:00
parent 49648daec0
commit 32a1273d82
11 changed files with 997 additions and 313 deletions

View File

@@ -0,0 +1,138 @@
import { describe, expect, it, vi } from "vitest";
import {
formatAllowlistEntries,
parseAllowlistEntries,
promptChannelAccessConfig,
promptChannelAllowlist,
promptChannelAccessPolicy,
} from "./channel-access.js";
function createPrompter(params?: {
confirm?: (options: { message: string; initialValue: boolean }) => Promise<boolean>;
select?: (options: {
message: string;
options: Array<{ value: string; label: string }>;
initialValue?: string;
}) => Promise<string>;
text?: (options: {
message: string;
placeholder?: string;
initialValue?: string;
}) => Promise<string>;
}) {
return {
confirm: vi.fn(params?.confirm ?? (async () => true)),
select: vi.fn(params?.select ?? (async () => "allowlist")),
text: vi.fn(params?.text ?? (async () => "")),
};
}
describe("parseAllowlistEntries", () => {
it("splits comma/newline/semicolon-separated entries", () => {
expect(parseAllowlistEntries("alpha, beta\n gamma;delta")).toEqual([
"alpha",
"beta",
"gamma",
"delta",
]);
});
});
describe("formatAllowlistEntries", () => {
it("formats compact comma-separated output", () => {
expect(formatAllowlistEntries([" alpha ", "", "beta"])).toBe("alpha, beta");
});
});
describe("promptChannelAllowlist", () => {
it("uses existing entries as initial value", async () => {
const prompter = createPrompter({
text: async () => "one,two",
});
const result = await promptChannelAllowlist({
// oxlint-disable-next-line typescript/no-explicit-any
prompter: prompter as any,
label: "Test",
currentEntries: ["alpha", "beta"],
});
expect(result).toEqual(["one", "two"]);
expect(prompter.text).toHaveBeenCalledWith(
expect.objectContaining({
initialValue: "alpha, beta",
}),
);
});
});
describe("promptChannelAccessPolicy", () => {
it("returns selected policy", async () => {
const prompter = createPrompter({
select: async () => "open",
});
const result = await promptChannelAccessPolicy({
// oxlint-disable-next-line typescript/no-explicit-any
prompter: prompter as any,
label: "Discord",
currentPolicy: "allowlist",
});
expect(result).toBe("open");
});
});
describe("promptChannelAccessConfig", () => {
it("returns null when user skips configuration", async () => {
const prompter = createPrompter({
confirm: async () => false,
});
const result = await promptChannelAccessConfig({
// oxlint-disable-next-line typescript/no-explicit-any
prompter: prompter as any,
label: "Slack",
});
expect(result).toBeNull();
});
it("returns allowlist entries when policy is allowlist", async () => {
const prompter = createPrompter({
confirm: async () => true,
select: async () => "allowlist",
text: async () => "c1, c2",
});
const result = await promptChannelAccessConfig({
// oxlint-disable-next-line typescript/no-explicit-any
prompter: prompter as any,
label: "Slack",
});
expect(result).toEqual({
policy: "allowlist",
entries: ["c1", "c2"],
});
});
it("returns non-allowlist policy with empty entries", async () => {
const prompter = createPrompter({
confirm: async () => true,
select: async () => "open",
});
const result = await promptChannelAccessConfig({
// oxlint-disable-next-line typescript/no-explicit-any
prompter: prompter as any,
label: "Slack",
allowDisabled: true,
});
expect(result).toEqual({
policy: "open",
entries: [],
});
});
});

View File

@@ -1,12 +1,10 @@
import type { WizardPrompter } from "../../../wizard/prompts.js";
import { splitOnboardingEntries } from "./helpers.js";
export type ChannelAccessPolicy = "allowlist" | "open" | "disabled";
export function parseAllowlistEntries(raw: string): string[] {
return String(raw ?? "")
.split(/[,\n]/g)
.map((entry) => entry.trim())
.filter(Boolean);
return splitOnboardingEntries(String(raw ?? ""));
}
export function formatAllowlistEntries(entries: string[]): string {

View File

@@ -12,12 +12,18 @@ import {
type DiscordChannelResolution,
} from "../../../discord/resolve-channels.js";
import { resolveDiscordUserAllowlist } from "../../../discord/resolve-users.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
import { promptChannelAccessConfig } from "./channel-access.js";
import { addWildcardAllowFrom, promptAccountId, promptResolvedAllowFrom } from "./helpers.js";
import {
addWildcardAllowFrom,
promptResolvedAllowFrom,
resolveAccountIdForConfigure,
resolveOnboardingAccountId,
splitOnboardingEntries,
} from "./helpers.js";
const channel = "discord" as const;
@@ -145,22 +151,15 @@ function setDiscordAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClaw
};
}
function parseDiscordAllowFromInput(raw: string): string[] {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
async function promptDiscordAllowFrom(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultDiscordAccountId(params.cfg);
const accountId = resolveOnboardingAccountId({
accountId: params.accountId,
defaultAccountId: resolveDefaultDiscordAccountId(params.cfg),
});
const resolved = resolveDiscordAccount({ cfg: params.cfg, accountId });
const token = resolved.token;
const existing =
@@ -178,7 +177,7 @@ async function promptDiscordAllowFrom(params: {
"Discord allowlist",
);
const parseInputs = (value: string) => parseDiscordAllowFromInput(value);
const parseInputs = (value: string) => splitOnboardingEntries(value);
const parseId = (value: string) => {
const trimmed = value.trim();
if (!trimmed) {
@@ -240,21 +239,16 @@ export const discordOnboardingAdapter: ChannelOnboardingAdapter = {
};
},
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
const discordOverride = accountOverrides.discord?.trim();
const defaultDiscordAccountId = resolveDefaultDiscordAccountId(cfg);
let discordAccountId = discordOverride
? normalizeAccountId(discordOverride)
: defaultDiscordAccountId;
if (shouldPromptAccountIds && !discordOverride) {
discordAccountId = await promptAccountId({
cfg,
prompter,
label: "Discord",
currentId: discordAccountId,
listAccountIds: listDiscordAccountIds,
defaultAccountId: defaultDiscordAccountId,
});
}
const discordAccountId = await resolveAccountIdForConfigure({
cfg,
prompter,
label: "Discord",
accountOverride: accountOverrides.discord,
shouldPromptAccountIds,
listAccountIds: listDiscordAccountIds,
defaultAccountId: defaultDiscordAccountId,
});
let next = cfg;
const resolvedAccount = resolveDiscordAccount({

View File

@@ -1,5 +1,21 @@
import { describe, expect, it, vi } from "vitest";
import { promptResolvedAllowFrom } from "./helpers.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
const promptAccountIdSdkMock = vi.hoisted(() => vi.fn(async () => "default"));
vi.mock("../../../plugin-sdk/onboarding.js", () => ({
promptAccountId: promptAccountIdSdkMock,
}));
import {
normalizeAllowFromEntries,
promptResolvedAllowFrom,
resolveAccountIdForConfigure,
resolveOnboardingAccountId,
setAccountAllowFromForChannel,
setChannelDmPolicyWithAllowFrom,
splitOnboardingEntries,
} from "./helpers.js";
function createPrompter(inputs: string[]) {
return {
@@ -9,6 +25,11 @@ function createPrompter(inputs: string[]) {
}
describe("promptResolvedAllowFrom", () => {
beforeEach(() => {
promptAccountIdSdkMock.mockReset();
promptAccountIdSdkMock.mockResolvedValue("default");
});
it("re-prompts without token until all ids are parseable", async () => {
const prompter = createPrompter(["@alice", "123"]);
const resolveEntries = vi.fn();
@@ -66,4 +87,227 @@ describe("promptResolvedAllowFrom", () => {
expect(prompter.note).toHaveBeenCalledWith("Could not resolve: alice", "allowlist");
expect(resolveEntries).toHaveBeenCalledTimes(2);
});
it("re-prompts when resolver throws before succeeding", async () => {
const prompter = createPrompter(["alice", "bob"]);
const resolveEntries = vi
.fn()
.mockRejectedValueOnce(new Error("network"))
.mockResolvedValueOnce([{ input: "bob", resolved: true, id: "U234" }]);
const result = await promptResolvedAllowFrom({
// oxlint-disable-next-line typescript/no-explicit-any
prompter: prompter as any,
existing: [],
token: "xoxb-test",
message: "msg",
placeholder: "placeholder",
label: "allowlist",
parseInputs: (value) =>
value
.split(",")
.map((part) => part.trim())
.filter(Boolean),
parseId: () => null,
invalidWithoutTokenNote: "ids only",
resolveEntries,
});
expect(result).toEqual(["U234"]);
expect(prompter.note).toHaveBeenCalledWith(
"Failed to resolve usernames. Try again.",
"allowlist",
);
expect(resolveEntries).toHaveBeenCalledTimes(2);
});
});
describe("setAccountAllowFromForChannel", () => {
it("writes allowFrom on default account channel config", () => {
const cfg: OpenClawConfig = {
channels: {
imessage: {
enabled: true,
allowFrom: ["old"],
accounts: {
work: { allowFrom: ["work-old"] },
},
},
},
};
const next = setAccountAllowFromForChannel({
cfg,
channel: "imessage",
accountId: DEFAULT_ACCOUNT_ID,
allowFrom: ["new-default"],
});
expect(next.channels?.imessage?.allowFrom).toEqual(["new-default"]);
expect(next.channels?.imessage?.accounts?.work?.allowFrom).toEqual(["work-old"]);
});
it("writes allowFrom on nested non-default account config", () => {
const cfg: OpenClawConfig = {
channels: {
signal: {
enabled: true,
allowFrom: ["default-old"],
accounts: {
alt: { enabled: true, account: "+15555550123", allowFrom: ["alt-old"] },
},
},
},
};
const next = setAccountAllowFromForChannel({
cfg,
channel: "signal",
accountId: "alt",
allowFrom: ["alt-new"],
});
expect(next.channels?.signal?.allowFrom).toEqual(["default-old"]);
expect(next.channels?.signal?.accounts?.alt?.allowFrom).toEqual(["alt-new"]);
expect(next.channels?.signal?.accounts?.alt?.account).toBe("+15555550123");
});
});
describe("setChannelDmPolicyWithAllowFrom", () => {
it("adds wildcard allowFrom when setting dmPolicy=open", () => {
const cfg: OpenClawConfig = {
channels: {
signal: {
dmPolicy: "pairing",
allowFrom: ["+15555550123"],
},
},
};
const next = setChannelDmPolicyWithAllowFrom({
cfg,
channel: "signal",
dmPolicy: "open",
});
expect(next.channels?.signal?.dmPolicy).toBe("open");
expect(next.channels?.signal?.allowFrom).toEqual(["+15555550123", "*"]);
});
it("sets dmPolicy without changing allowFrom for non-open policies", () => {
const cfg: OpenClawConfig = {
channels: {
imessage: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
};
const next = setChannelDmPolicyWithAllowFrom({
cfg,
channel: "imessage",
dmPolicy: "pairing",
});
expect(next.channels?.imessage?.dmPolicy).toBe("pairing");
expect(next.channels?.imessage?.allowFrom).toEqual(["*"]);
});
});
describe("splitOnboardingEntries", () => {
it("splits comma/newline/semicolon input and trims blanks", () => {
expect(splitOnboardingEntries(" alice, bob \ncarol; ;\n")).toEqual(["alice", "bob", "carol"]);
});
});
describe("normalizeAllowFromEntries", () => {
it("normalizes values, preserves wildcard, and removes duplicates", () => {
expect(
normalizeAllowFromEntries([" +15555550123 ", "*", "+15555550123", "bad"], (value) =>
value.startsWith("+1") ? value : null,
),
).toEqual(["+15555550123", "*"]);
});
it("trims and de-duplicates without a normalizer", () => {
expect(normalizeAllowFromEntries([" alice ", "bob", "alice"])).toEqual(["alice", "bob"]);
});
});
describe("resolveOnboardingAccountId", () => {
it("normalizes provided account ids", () => {
expect(
resolveOnboardingAccountId({
accountId: " Work Account ",
defaultAccountId: DEFAULT_ACCOUNT_ID,
}),
).toBe("work-account");
});
it("falls back to default account id when input is blank", () => {
expect(
resolveOnboardingAccountId({
accountId: " ",
defaultAccountId: "custom-default",
}),
).toBe("custom-default");
});
});
describe("resolveAccountIdForConfigure", () => {
beforeEach(() => {
promptAccountIdSdkMock.mockReset();
promptAccountIdSdkMock.mockResolvedValue("default");
});
it("uses normalized override without prompting", async () => {
const accountId = await resolveAccountIdForConfigure({
cfg: {},
// oxlint-disable-next-line typescript/no-explicit-any
prompter: {} as any,
label: "Signal",
accountOverride: " Team Primary ",
shouldPromptAccountIds: true,
listAccountIds: () => ["default", "team-primary"],
defaultAccountId: DEFAULT_ACCOUNT_ID,
});
expect(accountId).toBe("team-primary");
});
it("uses default account when override is missing and prompting disabled", async () => {
const accountId = await resolveAccountIdForConfigure({
cfg: {},
// oxlint-disable-next-line typescript/no-explicit-any
prompter: {} as any,
label: "Signal",
shouldPromptAccountIds: false,
listAccountIds: () => ["default"],
defaultAccountId: "fallback",
});
expect(accountId).toBe("fallback");
});
it("prompts for account id when prompting is enabled and no override is provided", async () => {
promptAccountIdSdkMock.mockResolvedValueOnce("prompted-id");
const accountId = await resolveAccountIdForConfigure({
cfg: {},
// oxlint-disable-next-line typescript/no-explicit-any
prompter: {} as any,
label: "Signal",
shouldPromptAccountIds: true,
listAccountIds: () => ["default", "prompted-id"],
defaultAccountId: "fallback",
});
expect(accountId).toBe("prompted-id");
expect(promptAccountIdSdkMock).toHaveBeenCalledWith(
expect.objectContaining({
label: "Signal",
currentId: "fallback",
defaultAccountId: "fallback",
}),
);
});
});

View File

@@ -1,4 +1,7 @@
import type { OpenClawConfig } from "../../../config/config.js";
import type { DmPolicy } from "../../../config/types.js";
import { promptAccountId as promptAccountIdSdk } from "../../../plugin-sdk/onboarding.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { PromptAccountId, PromptAccountIdParams } from "../onboarding-types.js";
@@ -22,6 +25,123 @@ export function mergeAllowFromEntries(
return [...new Set(merged)];
}
export function splitOnboardingEntries(raw: string): string[] {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
export function normalizeAllowFromEntries(
entries: Array<string | number>,
normalizeEntry?: (value: string) => string | null | undefined,
): string[] {
const normalized = entries
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => {
if (entry === "*") {
return "*";
}
if (!normalizeEntry) {
return entry;
}
const value = normalizeEntry(entry);
return typeof value === "string" ? value.trim() : "";
})
.filter(Boolean);
return [...new Set(normalized)];
}
export function resolveOnboardingAccountId(params: {
accountId?: string;
defaultAccountId: string;
}): string {
return params.accountId?.trim() ? normalizeAccountId(params.accountId) : params.defaultAccountId;
}
export async function resolveAccountIdForConfigure(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
label: string;
accountOverride?: string;
shouldPromptAccountIds: boolean;
listAccountIds: (cfg: OpenClawConfig) => string[];
defaultAccountId: string;
}): Promise<string> {
const override = params.accountOverride?.trim();
let accountId = override ? normalizeAccountId(override) : params.defaultAccountId;
if (params.shouldPromptAccountIds && !override) {
accountId = await promptAccountId({
cfg: params.cfg,
prompter: params.prompter,
label: params.label,
currentId: accountId,
listAccountIds: params.listAccountIds,
defaultAccountId: params.defaultAccountId,
});
}
return accountId;
}
export function setAccountAllowFromForChannel(params: {
cfg: OpenClawConfig;
channel: "imessage" | "signal";
accountId: string;
allowFrom: string[];
}): OpenClawConfig {
const { cfg, channel, accountId, allowFrom } = params;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
[channel]: {
...cfg.channels?.[channel],
allowFrom,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
[channel]: {
...cfg.channels?.[channel],
accounts: {
...cfg.channels?.[channel]?.accounts,
[accountId]: {
...cfg.channels?.[channel]?.accounts?.[accountId],
allowFrom,
},
},
},
},
};
}
export function setChannelDmPolicyWithAllowFrom(params: {
cfg: OpenClawConfig;
channel: "imessage" | "signal";
dmPolicy: DmPolicy;
}): OpenClawConfig {
const { cfg, channel, dmPolicy } = params;
const allowFrom =
dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.[channel]?.allowFrom) : undefined;
return {
...cfg,
channels: {
...cfg.channels,
[channel]: {
...cfg.channels?.[channel],
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
type AllowFromResolution = {
input: string;
resolved: boolean;

View File

@@ -7,70 +7,27 @@ import {
resolveIMessageAccount,
} from "../../../imessage/accounts.js";
import { normalizeIMessageHandle } from "../../../imessage/targets.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
import { addWildcardAllowFrom, mergeAllowFromEntries, promptAccountId } from "./helpers.js";
import {
mergeAllowFromEntries,
resolveAccountIdForConfigure,
resolveOnboardingAccountId,
setAccountAllowFromForChannel,
setChannelDmPolicyWithAllowFrom,
splitOnboardingEntries,
} from "./helpers.js";
const channel = "imessage" as const;
function setIMessageDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) {
const allowFrom =
dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.imessage?.allowFrom) : undefined;
return {
...cfg,
channels: {
...cfg.channels,
imessage: {
...cfg.channels?.imessage,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
function setIMessageAllowFrom(
cfg: OpenClawConfig,
accountId: string,
allowFrom: string[],
): OpenClawConfig {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
imessage: {
...cfg.channels?.imessage,
allowFrom,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
imessage: {
...cfg.channels?.imessage,
accounts: {
...cfg.channels?.imessage?.accounts,
[accountId]: {
...cfg.channels?.imessage?.accounts?.[accountId],
allowFrom,
},
},
},
},
};
}
function parseIMessageAllowFromInput(raw: string): string[] {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
return setChannelDmPolicyWithAllowFrom({
cfg,
channel: "imessage",
dmPolicy,
});
}
async function promptIMessageAllowFrom(params: {
@@ -78,10 +35,10 @@ async function promptIMessageAllowFrom(params: {
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultIMessageAccountId(params.cfg);
const accountId = resolveOnboardingAccountId({
accountId: params.accountId,
defaultAccountId: resolveDefaultIMessageAccountId(params.cfg),
});
const resolved = resolveIMessageAccount({ cfg: params.cfg, accountId });
const existing = resolved.config.allowFrom ?? [];
await params.prompter.note(
@@ -106,7 +63,7 @@ async function promptIMessageAllowFrom(params: {
if (!raw) {
return "Required";
}
const parts = parseIMessageAllowFromInput(raw);
const parts = splitOnboardingEntries(raw);
for (const part of parts) {
if (part === "*") {
continue;
@@ -137,9 +94,14 @@ async function promptIMessageAllowFrom(params: {
return undefined;
},
});
const parts = parseIMessageAllowFromInput(String(entry));
const parts = splitOnboardingEntries(String(entry));
const unique = mergeAllowFromEntries(undefined, parts);
return setIMessageAllowFrom(params.cfg, accountId, unique);
return setAccountAllowFromForChannel({
cfg: params.cfg,
channel: "imessage",
accountId,
allowFrom: unique,
});
}
const dmPolicy: ChannelOnboardingDmPolicy = {
@@ -179,21 +141,16 @@ export const imessageOnboardingAdapter: ChannelOnboardingAdapter = {
};
},
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
const imessageOverride = accountOverrides.imessage?.trim();
const defaultIMessageAccountId = resolveDefaultIMessageAccountId(cfg);
let imessageAccountId = imessageOverride
? normalizeAccountId(imessageOverride)
: defaultIMessageAccountId;
if (shouldPromptAccountIds && !imessageOverride) {
imessageAccountId = await promptAccountId({
cfg,
prompter,
label: "iMessage",
currentId: imessageAccountId,
listAccountIds: listIMessageAccountIds,
defaultAccountId: defaultIMessageAccountId,
});
}
const imessageAccountId = await resolveAccountIdForConfigure({
cfg,
prompter,
label: "iMessage",
accountOverride: accountOverrides.imessage,
shouldPromptAccountIds,
listAccountIds: listIMessageAccountIds,
defaultAccountId: defaultIMessageAccountId,
});
let next = cfg;
const resolvedAccount = resolveIMessageAccount({

View File

@@ -3,7 +3,7 @@ import { detectBinary } from "../../../commands/onboard-helpers.js";
import { installSignalCli } from "../../../commands/signal-install.js";
import type { OpenClawConfig } from "../../../config/config.js";
import type { DmPolicy } from "../../../config/types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
import {
listSignalAccountIds,
resolveDefaultSignalAccountId,
@@ -13,7 +13,14 @@ 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, mergeAllowFromEntries, promptAccountId } from "./helpers.js";
import {
mergeAllowFromEntries,
resolveAccountIdForConfigure,
resolveOnboardingAccountId,
setAccountAllowFromForChannel,
setChannelDmPolicyWithAllowFrom,
splitOnboardingEntries,
} from "./helpers.js";
const channel = "signal" as const;
const MIN_E164_DIGITS = 5;
@@ -39,61 +46,11 @@ export function normalizeSignalAccountInput(value: string | null | undefined): s
}
function setSignalDmPolicy(cfg: OpenClawConfig, 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: OpenClawConfig,
accountId: string,
allowFrom: string[],
): OpenClawConfig {
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);
return setChannelDmPolicyWithAllowFrom({
cfg,
channel: "signal",
dmPolicy,
});
}
function isUuidLike(value: string): boolean {
@@ -105,10 +62,10 @@ async function promptSignalAllowFrom(params: {
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultSignalAccountId(params.cfg);
const accountId = resolveOnboardingAccountId({
accountId: params.accountId,
defaultAccountId: resolveDefaultSignalAccountId(params.cfg),
});
const resolved = resolveSignalAccount({ cfg: params.cfg, accountId });
const existing = resolved.config.allowFrom ?? [];
await params.prompter.note(
@@ -131,7 +88,7 @@ async function promptSignalAllowFrom(params: {
if (!raw) {
return "Required";
}
const parts = parseSignalAllowFromInput(raw);
const parts = splitOnboardingEntries(raw);
for (const part of parts) {
if (part === "*") {
continue;
@@ -152,7 +109,7 @@ async function promptSignalAllowFrom(params: {
return undefined;
},
});
const parts = parseSignalAllowFromInput(String(entry));
const parts = splitOnboardingEntries(String(entry));
const normalized = parts.map((part) => {
if (part === "*") {
return "*";
@@ -169,7 +126,12 @@ async function promptSignalAllowFrom(params: {
undefined,
normalized.filter((part): part is string => typeof part === "string" && part.trim().length > 0),
);
return setSignalAllowFrom(params.cfg, accountId, unique);
return setAccountAllowFromForChannel({
cfg: params.cfg,
channel: "signal",
accountId,
allowFrom: unique,
});
}
const dmPolicy: ChannelOnboardingDmPolicy = {
@@ -209,21 +171,16 @@ export const signalOnboardingAdapter: ChannelOnboardingAdapter = {
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,
});
}
const signalAccountId = await resolveAccountIdForConfigure({
cfg,
prompter,
label: "Signal",
accountOverride: accountOverrides.signal,
shouldPromptAccountIds,
listAccountIds: listSignalAccountIds,
defaultAccountId: defaultSignalAccountId,
});
let next = cfg;
const resolvedAccount = resolveSignalAccount({

View File

@@ -1,6 +1,6 @@
import type { OpenClawConfig } from "../../../config/config.js";
import type { DmPolicy } from "../../../config/types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
import {
listSlackAccountIds,
resolveDefaultSlackAccountId,
@@ -12,21 +12,27 @@ import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
import { promptChannelAccessConfig } from "./channel-access.js";
import { addWildcardAllowFrom, promptAccountId, promptResolvedAllowFrom } from "./helpers.js";
import {
addWildcardAllowFrom,
promptResolvedAllowFrom,
resolveAccountIdForConfigure,
resolveOnboardingAccountId,
splitOnboardingEntries,
} from "./helpers.js";
const channel = "slack" as const;
function setSlackDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) {
const existingAllowFrom = cfg.channels?.slack?.allowFrom ?? cfg.channels?.slack?.dm?.allowFrom;
const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined;
function patchSlackConfigWithDm(
cfg: OpenClawConfig,
patch: Record<string, unknown>,
): OpenClawConfig {
return {
...cfg,
channels: {
...cfg.channels,
slack: {
...cfg.channels?.slack,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
...patch,
dm: {
...cfg.channels?.slack?.dm,
enabled: cfg.channels?.slack?.dm?.enabled ?? true,
@@ -36,6 +42,15 @@ function setSlackDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) {
};
}
function setSlackDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) {
const existingAllowFrom = cfg.channels?.slack?.allowFrom ?? cfg.channels?.slack?.dm?.allowFrom;
const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(existingAllowFrom) : undefined;
return patchSlackConfigWithDm(cfg, {
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
});
}
function buildSlackManifest(botName: string) {
const safeName = botName.trim() || "OpenClaw";
const manifest = {
@@ -199,27 +214,7 @@ function setSlackChannelAllowlist(
}
function setSlackAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig {
return {
...cfg,
channels: {
...cfg.channels,
slack: {
...cfg.channels?.slack,
allowFrom,
dm: {
...cfg.channels?.slack?.dm,
enabled: cfg.channels?.slack?.dm?.enabled ?? true,
},
},
},
};
}
function parseSlackAllowFromInput(raw: string): string[] {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
return patchSlackConfigWithDm(cfg, { allowFrom });
}
async function promptSlackAllowFrom(params: {
@@ -227,10 +222,10 @@ async function promptSlackAllowFrom(params: {
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultSlackAccountId(params.cfg);
const accountId = resolveOnboardingAccountId({
accountId: params.accountId,
defaultAccountId: resolveDefaultSlackAccountId(params.cfg),
});
const resolved = resolveSlackAccount({ cfg: params.cfg, accountId });
const token = resolved.config.userToken ?? resolved.config.botToken ?? "";
const existing =
@@ -246,7 +241,7 @@ async function promptSlackAllowFrom(params: {
].join("\n"),
"Slack allowlist",
);
const parseInputs = (value: string) => parseSlackAllowFromInput(value);
const parseInputs = (value: string) => splitOnboardingEntries(value);
const parseId = (value: string) => {
const trimmed = value.trim();
if (!trimmed) {
@@ -309,19 +304,16 @@ export const slackOnboardingAdapter: ChannelOnboardingAdapter = {
};
},
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
const slackOverride = accountOverrides.slack?.trim();
const defaultSlackAccountId = resolveDefaultSlackAccountId(cfg);
let slackAccountId = slackOverride ? normalizeAccountId(slackOverride) : defaultSlackAccountId;
if (shouldPromptAccountIds && !slackOverride) {
slackAccountId = await promptAccountId({
cfg,
prompter,
label: "Slack",
currentId: slackAccountId,
listAccountIds: listSlackAccountIds,
defaultAccountId: defaultSlackAccountId,
});
}
const slackAccountId = await resolveAccountIdForConfigure({
cfg,
prompter,
label: "Slack",
accountOverride: accountOverrides.slack,
shouldPromptAccountIds,
listAccountIds: listSlackAccountIds,
defaultAccountId: defaultSlackAccountId,
});
let next = cfg;
const resolvedAccount = resolveSlackAccount({

View File

@@ -1,7 +1,7 @@
import { formatCliCommand } from "../../../cli/command-format.js";
import type { OpenClawConfig } from "../../../config/config.js";
import type { DmPolicy } from "../../../config/types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
import {
listTelegramAccountIds,
resolveDefaultTelegramAccountId,
@@ -11,7 +11,13 @@ import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import { fetchTelegramChatId } from "../../telegram/api.js";
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
import { addWildcardAllowFrom, mergeAllowFromEntries, promptAccountId } from "./helpers.js";
import {
addWildcardAllowFrom,
mergeAllowFromEntries,
resolveAccountIdForConfigure,
resolveOnboardingAccountId,
splitOnboardingEntries,
} from "./helpers.js";
const channel = "telegram" as const;
@@ -89,12 +95,6 @@ async function promptTelegramAllowFrom(params: {
return await fetchTelegramChatId({ token, chatId: username });
};
const parseInput = (value: string) =>
value
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
let resolvedIds: string[] = [];
while (resolvedIds.length === 0) {
const entry = await prompter.text({
@@ -103,7 +103,7 @@ async function promptTelegramAllowFrom(params: {
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = parseInput(String(entry));
const parts = splitOnboardingEntries(String(entry));
const results = await Promise.all(parts.map((part) => resolveTelegramUserId(part)));
const unresolved = parts.filter((_, idx) => !results[idx]);
if (unresolved.length > 0) {
@@ -159,10 +159,10 @@ async function promptTelegramAllowFromForAccount(params: {
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
const accountId =
params.accountId && normalizeAccountId(params.accountId)
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
: resolveDefaultTelegramAccountId(params.cfg);
const accountId = resolveOnboardingAccountId({
accountId: params.accountId,
defaultAccountId: resolveDefaultTelegramAccountId(params.cfg),
});
return promptTelegramAllowFrom({
cfg: params.cfg,
prompter: params.prompter,
@@ -201,21 +201,16 @@ export const telegramOnboardingAdapter: ChannelOnboardingAdapter = {
shouldPromptAccountIds,
forceAllowFrom,
}) => {
const telegramOverride = accountOverrides.telegram?.trim();
const defaultTelegramAccountId = resolveDefaultTelegramAccountId(cfg);
let telegramAccountId = telegramOverride
? normalizeAccountId(telegramOverride)
: defaultTelegramAccountId;
if (shouldPromptAccountIds && !telegramOverride) {
telegramAccountId = await promptAccountId({
cfg,
prompter,
label: "Telegram",
currentId: telegramAccountId,
listAccountIds: listTelegramAccountIds,
defaultAccountId: defaultTelegramAccountId,
});
}
const telegramAccountId = await resolveAccountIdForConfigure({
cfg,
prompter,
label: "Telegram",
accountOverride: accountOverrides.telegram,
shouldPromptAccountIds,
listAccountIds: listTelegramAccountIds,
defaultAccountId: defaultTelegramAccountId,
});
let next = cfg;
const resolvedAccount = resolveTelegramAccount({

View File

@@ -0,0 +1,287 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
import type { RuntimeEnv } from "../../../runtime.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import { whatsappOnboardingAdapter } from "./whatsapp.js";
const loginWebMock = vi.hoisted(() => vi.fn(async () => {}));
const pathExistsMock = vi.hoisted(() => vi.fn(async () => false));
const listWhatsAppAccountIdsMock = vi.hoisted(() => vi.fn(() => [] as string[]));
const resolveDefaultWhatsAppAccountIdMock = vi.hoisted(() => vi.fn(() => DEFAULT_ACCOUNT_ID));
const resolveWhatsAppAuthDirMock = vi.hoisted(() =>
vi.fn(() => ({
authDir: "/tmp/openclaw-whatsapp-test",
})),
);
vi.mock("../../../channel-web.js", () => ({
loginWeb: loginWebMock,
}));
vi.mock("../../../utils.js", async () => {
const actual = await vi.importActual<typeof import("../../../utils.js")>("../../../utils.js");
return {
...actual,
pathExists: pathExistsMock,
};
});
vi.mock("../../../web/accounts.js", () => ({
listWhatsAppAccountIds: listWhatsAppAccountIdsMock,
resolveDefaultWhatsAppAccountId: resolveDefaultWhatsAppAccountIdMock,
resolveWhatsAppAuthDir: resolveWhatsAppAuthDirMock,
}));
function createPrompterHarness(params?: {
selectValues?: string[];
textValues?: string[];
confirmValues?: boolean[];
}) {
const selectValues = [...(params?.selectValues ?? [])];
const textValues = [...(params?.textValues ?? [])];
const confirmValues = [...(params?.confirmValues ?? [])];
const intro = vi.fn(async () => undefined);
const outro = vi.fn(async () => undefined);
const note = vi.fn(async () => undefined);
const select = vi.fn(async () => selectValues.shift() ?? "");
const multiselect = vi.fn(async () => [] as string[]);
const text = vi.fn(async () => textValues.shift() ?? "");
const confirm = vi.fn(async () => confirmValues.shift() ?? false);
const progress = vi.fn(() => ({
update: vi.fn(),
stop: vi.fn(),
}));
return {
intro,
outro,
note,
select,
multiselect,
text,
confirm,
progress,
prompter: {
intro,
outro,
note,
select,
multiselect,
text,
confirm,
progress,
} as WizardPrompter,
};
}
function createRuntime(): RuntimeEnv {
return {
error: vi.fn(),
} as unknown as RuntimeEnv;
}
describe("whatsappOnboardingAdapter.configure", () => {
beforeEach(() => {
vi.clearAllMocks();
pathExistsMock.mockResolvedValue(false);
listWhatsAppAccountIdsMock.mockReturnValue([]);
resolveDefaultWhatsAppAccountIdMock.mockReturnValue(DEFAULT_ACCOUNT_ID);
resolveWhatsAppAuthDirMock.mockReturnValue({ authDir: "/tmp/openclaw-whatsapp-test" });
});
it("applies owner allowlist when forceAllowFrom is enabled", async () => {
const harness = createPrompterHarness({
confirmValues: [false],
textValues: ["+1 (555) 555-0123"],
});
const result = await whatsappOnboardingAdapter.configure({
cfg: {},
runtime: createRuntime(),
prompter: harness.prompter,
options: {},
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: true,
});
expect(result.accountId).toBe(DEFAULT_ACCOUNT_ID);
expect(loginWebMock).not.toHaveBeenCalled();
expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(true);
expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("allowlist");
expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
expect(harness.text).toHaveBeenCalledWith(
expect.objectContaining({
message: "Your personal WhatsApp number (the phone you will message from)",
}),
);
});
it("supports disabled DM policy for separate-phone setup", async () => {
pathExistsMock.mockResolvedValue(true);
const harness = createPrompterHarness({
confirmValues: [false],
selectValues: ["separate", "disabled"],
});
const result = await whatsappOnboardingAdapter.configure({
cfg: {},
runtime: createRuntime(),
prompter: harness.prompter,
options: {},
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: false,
});
expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(false);
expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("disabled");
expect(result.cfg.channels?.whatsapp?.allowFrom).toBeUndefined();
expect(harness.text).not.toHaveBeenCalled();
});
it("normalizes allowFrom entries when list mode is selected", async () => {
pathExistsMock.mockResolvedValue(true);
const harness = createPrompterHarness({
confirmValues: [false],
selectValues: ["separate", "allowlist", "list"],
textValues: ["+1 (555) 555-0123, +15555550123, *"],
});
const result = await whatsappOnboardingAdapter.configure({
cfg: {},
runtime: createRuntime(),
prompter: harness.prompter,
options: {},
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: false,
});
expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(false);
expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("allowlist");
expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15555550123", "*"]);
});
it("enables allowlist self-chat mode for personal-phone setup", async () => {
pathExistsMock.mockResolvedValue(true);
const harness = createPrompterHarness({
confirmValues: [false],
selectValues: ["personal"],
textValues: ["+1 (555) 111-2222"],
});
const result = await whatsappOnboardingAdapter.configure({
cfg: {},
runtime: createRuntime(),
prompter: harness.prompter,
options: {},
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: false,
});
expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(true);
expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("allowlist");
expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15551112222"]);
});
it("forces wildcard allowFrom for open policy without allowFrom follow-up prompts", async () => {
pathExistsMock.mockResolvedValue(true);
const harness = createPrompterHarness({
confirmValues: [false],
selectValues: ["separate", "open"],
});
const result = await whatsappOnboardingAdapter.configure({
cfg: {
channels: {
whatsapp: {
allowFrom: ["+15555550123"],
},
},
},
runtime: createRuntime(),
prompter: harness.prompter,
options: {},
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: false,
});
expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(false);
expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("open");
expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["*", "+15555550123"]);
expect(harness.select).toHaveBeenCalledTimes(2);
expect(harness.text).not.toHaveBeenCalled();
});
it("runs WhatsApp login when not linked and user confirms linking", async () => {
pathExistsMock.mockResolvedValue(false);
const harness = createPrompterHarness({
confirmValues: [true],
selectValues: ["separate", "disabled"],
});
const runtime = createRuntime();
await whatsappOnboardingAdapter.configure({
cfg: {},
runtime,
prompter: harness.prompter,
options: {},
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: false,
});
expect(loginWebMock).toHaveBeenCalledWith(false, undefined, runtime, DEFAULT_ACCOUNT_ID);
});
it("skips relink note when already linked and relink is declined", async () => {
pathExistsMock.mockResolvedValue(true);
const harness = createPrompterHarness({
confirmValues: [false],
selectValues: ["separate", "disabled"],
});
await whatsappOnboardingAdapter.configure({
cfg: {},
runtime: createRuntime(),
prompter: harness.prompter,
options: {},
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: false,
});
expect(loginWebMock).not.toHaveBeenCalled();
expect(harness.note).not.toHaveBeenCalledWith(
expect.stringContaining("openclaw channels login"),
"WhatsApp",
);
});
it("shows follow-up login command note when not linked and linking is skipped", async () => {
pathExistsMock.mockResolvedValue(false);
const harness = createPrompterHarness({
confirmValues: [false],
selectValues: ["separate", "disabled"],
});
await whatsappOnboardingAdapter.configure({
cfg: {},
runtime: createRuntime(),
prompter: harness.prompter,
options: {},
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: false,
});
expect(harness.note).toHaveBeenCalledWith(
expect.stringContaining("openclaw channels login"),
"WhatsApp",
);
});
});

View File

@@ -4,7 +4,7 @@ import { formatCliCommand } from "../../../cli/command-format.js";
import type { OpenClawConfig } from "../../../config/config.js";
import { mergeWhatsAppConfig } from "../../../config/merge-config.js";
import type { DmPolicy } from "../../../config/types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
import type { RuntimeEnv } from "../../../runtime.js";
import { formatDocsLink } from "../../../terminal/links.js";
import { normalizeE164, pathExists } from "../../../utils.js";
@@ -15,7 +15,12 @@ import {
} from "../../../web/accounts.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { ChannelOnboardingAdapter } from "../onboarding-types.js";
import { mergeAllowFromEntries, promptAccountId } from "./helpers.js";
import {
normalizeAllowFromEntries,
resolveAccountIdForConfigure,
resolveOnboardingAccountId,
splitOnboardingEntries,
} from "./helpers.js";
const channel = "whatsapp" as const;
@@ -68,14 +73,10 @@ async function promptWhatsAppOwnerAllowFrom(params: {
if (!normalized) {
throw new Error("Invalid WhatsApp owner number (expected E.164 after validation).");
}
const merged = [
...existingAllowFrom
.filter((item) => item !== "*")
.map((item) => normalizeE164(item))
.filter((item): item is string => typeof item === "string" && item.trim().length > 0),
normalized,
];
const allowFrom = mergeAllowFromEntries(undefined, merged);
const allowFrom = normalizeAllowFromEntries(
[...existingAllowFrom.filter((item) => item !== "*"), normalized],
normalizeE164,
);
return { normalized, allowFrom };
}
@@ -100,6 +101,26 @@ async function applyWhatsAppOwnerAllowlist(params: {
return next;
}
function parseWhatsAppAllowFromEntries(raw: string): { entries: string[]; invalidEntry?: string } {
const parts = splitOnboardingEntries(raw);
if (parts.length === 0) {
return { entries: [] };
}
const entries: string[] = [];
for (const part of parts) {
if (part === "*") {
entries.push("*");
continue;
}
const normalized = normalizeE164(part);
if (!normalized) {
return { entries: [], invalidEntry: part };
}
entries.push(normalized);
}
return { entries: normalizeAllowFromEntries(entries, normalizeE164) };
}
async function promptWhatsAppAllowFrom(
cfg: OpenClawConfig,
_runtime: RuntimeEnv,
@@ -168,7 +189,9 @@ async function promptWhatsAppAllowFrom(
let next = setWhatsAppSelfChatMode(cfg, false);
next = setWhatsAppDmPolicy(next, policy);
if (policy === "open") {
next = setWhatsAppAllowFrom(next, ["*"]);
const allowFrom = normalizeAllowFromEntries(["*", ...existingAllowFrom], normalizeE164);
next = setWhatsAppAllowFrom(next, allowFrom.length > 0 ? allowFrom : ["*"]);
return next;
}
if (policy === "disabled") {
return next;
@@ -210,35 +233,19 @@ async function promptWhatsAppAllowFrom(
if (!raw) {
return "Required";
}
const parts = raw
.split(/[\n,;]+/g)
.map((p) => p.trim())
.filter(Boolean);
if (parts.length === 0) {
const parsed = parseWhatsAppAllowFromEntries(raw);
if (parsed.entries.length === 0 && !parsed.invalidEntry) {
return "Required";
}
for (const part of parts) {
if (part === "*") {
continue;
}
const normalized = normalizeE164(part);
if (!normalized) {
return `Invalid number: ${part}`;
}
if (parsed.invalidEntry) {
return `Invalid number: ${parsed.invalidEntry}`;
}
return undefined;
},
});
const parts = String(allowRaw)
.split(/[\n,;]+/g)
.map((p) => p.trim())
.filter(Boolean);
const normalized = parts
.map((part) => (part === "*" ? "*" : normalizeE164(part)))
.filter((part): part is string => typeof part === "string" && part.trim().length > 0);
const unique = mergeAllowFromEntries(undefined, normalized);
next = setWhatsAppAllowFrom(next, unique);
const parsed = parseWhatsAppAllowFromEntries(String(allowRaw));
next = setWhatsAppAllowFrom(next, parsed.entries);
}
return next;
@@ -247,9 +254,11 @@ async function promptWhatsAppAllowFrom(
export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg, accountOverrides }) => {
const overrideId = accountOverrides.whatsapp?.trim();
const defaultAccountId = resolveDefaultWhatsAppAccountId(cfg);
const accountId = overrideId ? normalizeAccountId(overrideId) : defaultAccountId;
const accountId = resolveOnboardingAccountId({
accountId: accountOverrides.whatsapp,
defaultAccountId,
});
const linked = await detectWhatsAppLinked(cfg, accountId);
const accountLabel = accountId === DEFAULT_ACCOUNT_ID ? "default" : accountId;
return {
@@ -269,22 +278,15 @@ export const whatsappOnboardingAdapter: ChannelOnboardingAdapter = {
shouldPromptAccountIds,
forceAllowFrom,
}) => {
const overrideId = accountOverrides.whatsapp?.trim();
let accountId = overrideId
? normalizeAccountId(overrideId)
: resolveDefaultWhatsAppAccountId(cfg);
if (shouldPromptAccountIds || options?.promptWhatsAppAccountId) {
if (!overrideId) {
accountId = await promptAccountId({
cfg,
prompter,
label: "WhatsApp",
currentId: accountId,
listAccountIds: listWhatsAppAccountIds,
defaultAccountId: resolveDefaultWhatsAppAccountId(cfg),
});
}
}
const accountId = await resolveAccountIdForConfigure({
cfg,
prompter,
label: "WhatsApp",
accountOverride: accountOverrides.whatsapp,
shouldPromptAccountIds: Boolean(shouldPromptAccountIds || options?.promptWhatsAppAccountId),
listAccountIds: listWhatsAppAccountIds,
defaultAccountId: resolveDefaultWhatsAppAccountId(cfg),
});
let next = cfg;
if (accountId !== DEFAULT_ACCOUNT_ID) {