mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 14:41:11 +00:00
refactor(onboarding): dedupe channel allowlist flows
This commit is contained in:
138
src/channels/plugins/onboarding/channel-access.test.ts
Normal file
138
src/channels/plugins/onboarding/channel-access.test.ts
Normal 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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
287
src/channels/plugins/onboarding/whatsapp.test.ts
Normal file
287
src/channels/plugins/onboarding/whatsapp.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user