1208 lines
35 KiB
TypeScript
1208 lines
35 KiB
TypeScript
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 {
|
|
applySingleTokenPromptResult,
|
|
buildSingleChannelSecretPromptState,
|
|
normalizeAllowFromEntries,
|
|
noteChannelLookupFailure,
|
|
noteChannelLookupSummary,
|
|
parseMentionOrPrefixedId,
|
|
parseOnboardingEntriesAllowingWildcard,
|
|
patchChannelConfigForAccount,
|
|
patchLegacyDmChannelConfig,
|
|
promptLegacyChannelAllowFrom,
|
|
parseOnboardingEntriesWithParser,
|
|
promptParsedAllowFromForScopedChannel,
|
|
promptSingleChannelSecretInput,
|
|
promptSingleChannelToken,
|
|
promptResolvedAllowFrom,
|
|
resolveAccountIdForConfigure,
|
|
resolveOnboardingAccountId,
|
|
setAccountAllowFromForChannel,
|
|
setAccountGroupPolicyForChannel,
|
|
setChannelDmPolicyWithAllowFrom,
|
|
setTopLevelChannelAllowFrom,
|
|
setTopLevelChannelDmPolicyWithAllowFrom,
|
|
setTopLevelChannelGroupPolicy,
|
|
setLegacyChannelAllowFrom,
|
|
setLegacyChannelDmPolicyWithAllowFrom,
|
|
setOnboardingChannelEnabled,
|
|
splitOnboardingEntries,
|
|
} from "./helpers.js";
|
|
|
|
function createPrompter(inputs: string[]) {
|
|
return {
|
|
text: vi.fn(async () => inputs.shift() ?? ""),
|
|
note: vi.fn(async () => undefined),
|
|
};
|
|
}
|
|
|
|
function createTokenPrompter(params: { confirms: boolean[]; texts: string[] }) {
|
|
const confirms = [...params.confirms];
|
|
const texts = [...params.texts];
|
|
return {
|
|
confirm: vi.fn(async () => confirms.shift() ?? true),
|
|
text: vi.fn(async () => texts.shift() ?? ""),
|
|
};
|
|
}
|
|
|
|
function parseCsvInputs(value: string): string[] {
|
|
return value
|
|
.split(",")
|
|
.map((part) => part.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
type AllowFromResolver = (params: {
|
|
token: string;
|
|
entries: string[];
|
|
}) => Promise<Array<{ input: string; resolved: boolean; id?: string | null }>>;
|
|
|
|
function asAllowFromResolver(resolveEntries: ReturnType<typeof vi.fn>): AllowFromResolver {
|
|
return resolveEntries as AllowFromResolver;
|
|
}
|
|
|
|
async function runPromptResolvedAllowFromWithToken(params: {
|
|
prompter: ReturnType<typeof createPrompter>;
|
|
resolveEntries: AllowFromResolver;
|
|
}) {
|
|
return await promptResolvedAllowFrom({
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
prompter: params.prompter as any,
|
|
existing: [],
|
|
token: "xoxb-test",
|
|
message: "msg",
|
|
placeholder: "placeholder",
|
|
label: "allowlist",
|
|
parseInputs: parseCsvInputs,
|
|
parseId: () => null,
|
|
invalidWithoutTokenNote: "ids only",
|
|
resolveEntries: params.resolveEntries,
|
|
});
|
|
}
|
|
|
|
async function runPromptSingleToken(params: {
|
|
prompter: ReturnType<typeof createTokenPrompter>;
|
|
accountConfigured: boolean;
|
|
canUseEnv: boolean;
|
|
hasConfigToken: boolean;
|
|
}) {
|
|
return await promptSingleChannelToken({
|
|
prompter: params.prompter,
|
|
accountConfigured: params.accountConfigured,
|
|
canUseEnv: params.canUseEnv,
|
|
hasConfigToken: params.hasConfigToken,
|
|
envPrompt: "use env",
|
|
keepPrompt: "keep",
|
|
inputPrompt: "token",
|
|
});
|
|
}
|
|
|
|
describe("buildSingleChannelSecretPromptState", () => {
|
|
it("enables env path only when env is present and no config token exists", () => {
|
|
expect(
|
|
buildSingleChannelSecretPromptState({
|
|
accountConfigured: false,
|
|
hasConfigToken: false,
|
|
allowEnv: true,
|
|
envValue: "token-from-env",
|
|
}),
|
|
).toEqual({
|
|
accountConfigured: false,
|
|
hasConfigToken: false,
|
|
canUseEnv: true,
|
|
});
|
|
});
|
|
|
|
it("disables env path when config token already exists", () => {
|
|
expect(
|
|
buildSingleChannelSecretPromptState({
|
|
accountConfigured: true,
|
|
hasConfigToken: true,
|
|
allowEnv: true,
|
|
envValue: "token-from-env",
|
|
}),
|
|
).toEqual({
|
|
accountConfigured: true,
|
|
hasConfigToken: true,
|
|
canUseEnv: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
async function runPromptLegacyAllowFrom(params: {
|
|
cfg?: OpenClawConfig;
|
|
channel: "discord" | "slack";
|
|
prompter: ReturnType<typeof createPrompter>;
|
|
existing: string[];
|
|
token: string;
|
|
noteTitle: string;
|
|
noteLines: string[];
|
|
parseId: (value: string) => string | null;
|
|
resolveEntries: AllowFromResolver;
|
|
}) {
|
|
return await promptLegacyChannelAllowFrom({
|
|
cfg: params.cfg ?? {},
|
|
channel: params.channel,
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
prompter: params.prompter as any,
|
|
existing: params.existing,
|
|
token: params.token,
|
|
noteTitle: params.noteTitle,
|
|
noteLines: params.noteLines,
|
|
message: "msg",
|
|
placeholder: "placeholder",
|
|
parseId: params.parseId,
|
|
invalidWithoutTokenNote: "ids only",
|
|
resolveEntries: params.resolveEntries,
|
|
});
|
|
}
|
|
|
|
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();
|
|
|
|
const result = await promptResolvedAllowFrom({
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
prompter: prompter as any,
|
|
existing: ["111"],
|
|
token: "",
|
|
message: "msg",
|
|
placeholder: "placeholder",
|
|
label: "allowlist",
|
|
parseInputs: parseCsvInputs,
|
|
parseId: (value) => (/^\d+$/.test(value.trim()) ? value.trim() : null),
|
|
invalidWithoutTokenNote: "ids only",
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
resolveEntries: resolveEntries as any,
|
|
});
|
|
|
|
expect(result).toEqual(["111", "123"]);
|
|
expect(prompter.note).toHaveBeenCalledWith("ids only", "allowlist");
|
|
expect(resolveEntries).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("re-prompts when token resolution returns unresolved entries", async () => {
|
|
const prompter = createPrompter(["alice", "bob"]);
|
|
const resolveEntries = vi
|
|
.fn()
|
|
.mockResolvedValueOnce([{ input: "alice", resolved: false }])
|
|
.mockResolvedValueOnce([{ input: "bob", resolved: true, id: "U123" }]);
|
|
|
|
const result = await runPromptResolvedAllowFromWithToken({
|
|
prompter,
|
|
resolveEntries: asAllowFromResolver(resolveEntries),
|
|
});
|
|
|
|
expect(result).toEqual(["U123"]);
|
|
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 runPromptResolvedAllowFromWithToken({
|
|
prompter,
|
|
resolveEntries: asAllowFromResolver(resolveEntries),
|
|
});
|
|
|
|
expect(result).toEqual(["U234"]);
|
|
expect(prompter.note).toHaveBeenCalledWith(
|
|
"Failed to resolve usernames. Try again.",
|
|
"allowlist",
|
|
);
|
|
expect(resolveEntries).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
describe("promptLegacyChannelAllowFrom", () => {
|
|
it("applies parsed ids without token resolution", async () => {
|
|
const prompter = createPrompter([" 123 "]);
|
|
const resolveEntries = vi.fn();
|
|
|
|
const next = await runPromptLegacyAllowFrom({
|
|
cfg: {} as OpenClawConfig,
|
|
channel: "discord",
|
|
existing: ["999"],
|
|
prompter,
|
|
token: "",
|
|
noteTitle: "Discord allowlist",
|
|
noteLines: ["line1", "line2"],
|
|
parseId: (value) => (/^\d+$/.test(value.trim()) ? value.trim() : null),
|
|
resolveEntries: asAllowFromResolver(resolveEntries),
|
|
});
|
|
|
|
expect(next.channels?.discord?.allowFrom).toEqual(["999", "123"]);
|
|
expect(prompter.note).toHaveBeenCalledWith("line1\nline2", "Discord allowlist");
|
|
expect(resolveEntries).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("uses resolver when token is present", async () => {
|
|
const prompter = createPrompter(["alice"]);
|
|
const resolveEntries = vi.fn(async () => [{ input: "alice", resolved: true, id: "U1" }]);
|
|
|
|
const next = await runPromptLegacyAllowFrom({
|
|
cfg: {} as OpenClawConfig,
|
|
channel: "slack",
|
|
prompter,
|
|
existing: [],
|
|
token: "xoxb-token",
|
|
noteTitle: "Slack allowlist",
|
|
noteLines: ["line"],
|
|
parseId: () => null,
|
|
resolveEntries: asAllowFromResolver(resolveEntries),
|
|
});
|
|
|
|
expect(next.channels?.slack?.allowFrom).toEqual(["U1"]);
|
|
expect(resolveEntries).toHaveBeenCalledWith({ token: "xoxb-token", entries: ["alice"] });
|
|
});
|
|
});
|
|
|
|
describe("promptSingleChannelToken", () => {
|
|
it("uses env tokens when confirmed", async () => {
|
|
const prompter = createTokenPrompter({ confirms: [true], texts: [] });
|
|
const result = await runPromptSingleToken({
|
|
prompter,
|
|
accountConfigured: false,
|
|
canUseEnv: true,
|
|
hasConfigToken: false,
|
|
});
|
|
expect(result).toEqual({ useEnv: true, token: null });
|
|
expect(prompter.text).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("prompts for token when env exists but user declines env", async () => {
|
|
const prompter = createTokenPrompter({ confirms: [false], texts: ["abc"] });
|
|
const result = await runPromptSingleToken({
|
|
prompter,
|
|
accountConfigured: false,
|
|
canUseEnv: true,
|
|
hasConfigToken: false,
|
|
});
|
|
expect(result).toEqual({ useEnv: false, token: "abc" });
|
|
});
|
|
|
|
it("keeps existing configured token when confirmed", async () => {
|
|
const prompter = createTokenPrompter({ confirms: [true], texts: [] });
|
|
const result = await runPromptSingleToken({
|
|
prompter,
|
|
accountConfigured: true,
|
|
canUseEnv: false,
|
|
hasConfigToken: true,
|
|
});
|
|
expect(result).toEqual({ useEnv: false, token: null });
|
|
expect(prompter.text).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("prompts for token when no env/config token is used", async () => {
|
|
const prompter = createTokenPrompter({ confirms: [false], texts: ["xyz"] });
|
|
const result = await runPromptSingleToken({
|
|
prompter,
|
|
accountConfigured: true,
|
|
canUseEnv: false,
|
|
hasConfigToken: false,
|
|
});
|
|
expect(result).toEqual({ useEnv: false, token: "xyz" });
|
|
});
|
|
});
|
|
|
|
describe("promptSingleChannelSecretInput", () => {
|
|
it("returns use-env action when plaintext mode selects env fallback", async () => {
|
|
const prompter = {
|
|
select: vi.fn(async () => "plaintext"),
|
|
confirm: vi.fn(async () => true),
|
|
text: vi.fn(async () => ""),
|
|
note: vi.fn(async () => undefined),
|
|
};
|
|
|
|
const result = await promptSingleChannelSecretInput({
|
|
cfg: {},
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
prompter: prompter as any,
|
|
providerHint: "telegram",
|
|
credentialLabel: "Telegram bot token",
|
|
accountConfigured: false,
|
|
canUseEnv: true,
|
|
hasConfigToken: false,
|
|
envPrompt: "use env",
|
|
keepPrompt: "keep",
|
|
inputPrompt: "token",
|
|
preferredEnvVar: "TELEGRAM_BOT_TOKEN",
|
|
});
|
|
|
|
expect(result).toEqual({ action: "use-env" });
|
|
});
|
|
|
|
it("returns ref + resolved value when external env ref is selected", async () => {
|
|
process.env.OPENCLAW_TEST_TOKEN = "secret-token";
|
|
const prompter = {
|
|
select: vi.fn().mockResolvedValueOnce("ref").mockResolvedValueOnce("env"),
|
|
confirm: vi.fn(async () => false),
|
|
text: vi.fn(async () => "OPENCLAW_TEST_TOKEN"),
|
|
note: vi.fn(async () => undefined),
|
|
};
|
|
|
|
const result = await promptSingleChannelSecretInput({
|
|
cfg: {},
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
prompter: prompter as any,
|
|
providerHint: "discord",
|
|
credentialLabel: "Discord bot token",
|
|
accountConfigured: false,
|
|
canUseEnv: false,
|
|
hasConfigToken: false,
|
|
envPrompt: "use env",
|
|
keepPrompt: "keep",
|
|
inputPrompt: "token",
|
|
preferredEnvVar: "OPENCLAW_TEST_TOKEN",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
action: "set",
|
|
value: {
|
|
source: "env",
|
|
provider: "default",
|
|
id: "OPENCLAW_TEST_TOKEN",
|
|
},
|
|
resolvedValue: "secret-token",
|
|
});
|
|
});
|
|
|
|
it("returns keep action when ref mode keeps an existing configured ref", async () => {
|
|
const prompter = {
|
|
select: vi.fn(async () => "ref"),
|
|
confirm: vi.fn(async () => true),
|
|
text: vi.fn(async () => ""),
|
|
note: vi.fn(async () => undefined),
|
|
};
|
|
|
|
const result = await promptSingleChannelSecretInput({
|
|
cfg: {},
|
|
// oxlint-disable-next-line typescript/no-explicit-any
|
|
prompter: prompter as any,
|
|
providerHint: "telegram",
|
|
credentialLabel: "Telegram bot token",
|
|
accountConfigured: true,
|
|
canUseEnv: false,
|
|
hasConfigToken: true,
|
|
envPrompt: "use env",
|
|
keepPrompt: "keep",
|
|
inputPrompt: "token",
|
|
preferredEnvVar: "TELEGRAM_BOT_TOKEN",
|
|
});
|
|
|
|
expect(result).toEqual({ action: "keep" });
|
|
expect(prompter.text).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("applySingleTokenPromptResult", () => {
|
|
it("writes env selection as an empty patch on target account", () => {
|
|
const next = applySingleTokenPromptResult({
|
|
cfg: {},
|
|
channel: "discord",
|
|
accountId: "work",
|
|
tokenPatchKey: "token",
|
|
tokenResult: { useEnv: true, token: null },
|
|
});
|
|
|
|
expect(next.channels?.discord?.enabled).toBe(true);
|
|
expect(next.channels?.discord?.accounts?.work?.enabled).toBe(true);
|
|
expect(next.channels?.discord?.accounts?.work?.token).toBeUndefined();
|
|
});
|
|
|
|
it("writes provided token under requested key", () => {
|
|
const next = applySingleTokenPromptResult({
|
|
cfg: {},
|
|
channel: "telegram",
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
tokenPatchKey: "botToken",
|
|
tokenResult: { useEnv: false, token: "abc" },
|
|
});
|
|
|
|
expect(next.channels?.telegram?.enabled).toBe(true);
|
|
expect(next.channels?.telegram?.botToken).toBe("abc");
|
|
});
|
|
});
|
|
|
|
describe("promptParsedAllowFromForScopedChannel", () => {
|
|
it("writes parsed allowFrom values to default account channel config", async () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
imessage: {
|
|
allowFrom: ["old"],
|
|
},
|
|
},
|
|
};
|
|
const prompter = createPrompter([" Alice, ALICE "]);
|
|
|
|
const next = await promptParsedAllowFromForScopedChannel({
|
|
cfg,
|
|
channel: "imessage",
|
|
defaultAccountId: DEFAULT_ACCOUNT_ID,
|
|
prompter,
|
|
noteTitle: "iMessage allowlist",
|
|
noteLines: ["line1", "line2"],
|
|
message: "msg",
|
|
placeholder: "placeholder",
|
|
parseEntries: (raw) =>
|
|
parseOnboardingEntriesWithParser(raw, (entry) => ({ value: entry.toLowerCase() })),
|
|
getExistingAllowFrom: ({ cfg }) => cfg.channels?.imessage?.allowFrom ?? [],
|
|
});
|
|
|
|
expect(next.channels?.imessage?.allowFrom).toEqual(["alice"]);
|
|
expect(prompter.note).toHaveBeenCalledWith("line1\nline2", "iMessage allowlist");
|
|
});
|
|
|
|
it("writes parsed values to non-default account allowFrom", async () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
signal: {
|
|
accounts: {
|
|
alt: {
|
|
allowFrom: ["+15555550123"],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const prompter = createPrompter(["+15555550124"]);
|
|
|
|
const next = await promptParsedAllowFromForScopedChannel({
|
|
cfg,
|
|
channel: "signal",
|
|
accountId: "alt",
|
|
defaultAccountId: DEFAULT_ACCOUNT_ID,
|
|
prompter,
|
|
noteTitle: "Signal allowlist",
|
|
noteLines: ["line"],
|
|
message: "msg",
|
|
placeholder: "placeholder",
|
|
parseEntries: (raw) => ({ entries: [raw.trim()] }),
|
|
getExistingAllowFrom: ({ cfg, accountId }) =>
|
|
cfg.channels?.signal?.accounts?.[accountId]?.allowFrom ?? [],
|
|
});
|
|
|
|
expect(next.channels?.signal?.accounts?.alt?.allowFrom).toEqual(["+15555550124"]);
|
|
expect(next.channels?.signal?.allowFrom).toBeUndefined();
|
|
});
|
|
|
|
it("uses parser validation from the prompt validate callback", async () => {
|
|
const prompter = {
|
|
note: vi.fn(async () => undefined),
|
|
text: vi.fn(async (params: { validate?: (value: string) => string | undefined }) => {
|
|
expect(params.validate?.("")).toBe("Required");
|
|
expect(params.validate?.("bad")).toBe("bad entry");
|
|
expect(params.validate?.("ok")).toBeUndefined();
|
|
return "ok";
|
|
}),
|
|
};
|
|
|
|
const next = await promptParsedAllowFromForScopedChannel({
|
|
cfg: {},
|
|
channel: "imessage",
|
|
defaultAccountId: DEFAULT_ACCOUNT_ID,
|
|
prompter,
|
|
noteTitle: "title",
|
|
noteLines: ["line"],
|
|
message: "msg",
|
|
placeholder: "placeholder",
|
|
parseEntries: (raw) =>
|
|
raw.trim() === "bad"
|
|
? { entries: [], error: "bad entry" }
|
|
: { entries: [raw.trim().toLowerCase()] },
|
|
getExistingAllowFrom: () => [],
|
|
});
|
|
|
|
expect(next.channels?.imessage?.allowFrom).toEqual(["ok"]);
|
|
});
|
|
});
|
|
|
|
describe("channel lookup note helpers", () => {
|
|
it("emits summary lines for resolved and unresolved entries", async () => {
|
|
const prompter = { note: vi.fn(async () => undefined) };
|
|
await noteChannelLookupSummary({
|
|
prompter,
|
|
label: "Slack channels",
|
|
resolvedSections: [
|
|
{ title: "Resolved", values: ["C1", "C2"] },
|
|
{ title: "Resolved guilds", values: [] },
|
|
],
|
|
unresolved: ["#typed-name"],
|
|
});
|
|
expect(prompter.note).toHaveBeenCalledWith(
|
|
"Resolved: C1, C2\nUnresolved (kept as typed): #typed-name",
|
|
"Slack channels",
|
|
);
|
|
});
|
|
|
|
it("skips note output when there is nothing to report", async () => {
|
|
const prompter = { note: vi.fn(async () => undefined) };
|
|
await noteChannelLookupSummary({
|
|
prompter,
|
|
label: "Discord channels",
|
|
resolvedSections: [{ title: "Resolved", values: [] }],
|
|
unresolved: [],
|
|
});
|
|
expect(prompter.note).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("formats lookup failures consistently", async () => {
|
|
const prompter = { note: vi.fn(async () => undefined) };
|
|
await noteChannelLookupFailure({
|
|
prompter,
|
|
label: "Discord channels",
|
|
error: new Error("boom"),
|
|
});
|
|
expect(prompter.note).toHaveBeenCalledWith(
|
|
"Channel lookup failed; keeping entries as typed. Error: boom",
|
|
"Discord channels",
|
|
);
|
|
});
|
|
});
|
|
|
|
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("patchChannelConfigForAccount", () => {
|
|
it("patches root channel config for default account", () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
telegram: {
|
|
enabled: false,
|
|
botToken: "old",
|
|
},
|
|
},
|
|
};
|
|
|
|
const next = patchChannelConfigForAccount({
|
|
cfg,
|
|
channel: "telegram",
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
patch: { botToken: "new", dmPolicy: "allowlist" },
|
|
});
|
|
|
|
expect(next.channels?.telegram?.enabled).toBe(true);
|
|
expect(next.channels?.telegram?.botToken).toBe("new");
|
|
expect(next.channels?.telegram?.dmPolicy).toBe("allowlist");
|
|
});
|
|
|
|
it("patches nested account config and preserves existing enabled flag", () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
slack: {
|
|
enabled: true,
|
|
accounts: {
|
|
work: {
|
|
enabled: false,
|
|
botToken: "old-bot",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const next = patchChannelConfigForAccount({
|
|
cfg,
|
|
channel: "slack",
|
|
accountId: "work",
|
|
patch: { botToken: "new-bot", appToken: "new-app" },
|
|
});
|
|
|
|
expect(next.channels?.slack?.enabled).toBe(true);
|
|
expect(next.channels?.slack?.accounts?.work?.enabled).toBe(false);
|
|
expect(next.channels?.slack?.accounts?.work?.botToken).toBe("new-bot");
|
|
expect(next.channels?.slack?.accounts?.work?.appToken).toBe("new-app");
|
|
});
|
|
|
|
it("moves single-account config into default account when patching non-default", () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
telegram: {
|
|
enabled: true,
|
|
botToken: "legacy-token",
|
|
allowFrom: ["100"],
|
|
groupPolicy: "allowlist",
|
|
streaming: "partial",
|
|
},
|
|
},
|
|
};
|
|
|
|
const next = patchChannelConfigForAccount({
|
|
cfg,
|
|
channel: "telegram",
|
|
accountId: "work",
|
|
patch: { botToken: "work-token" },
|
|
});
|
|
|
|
expect(next.channels?.telegram?.accounts?.default).toEqual({
|
|
botToken: "legacy-token",
|
|
allowFrom: ["100"],
|
|
groupPolicy: "allowlist",
|
|
streaming: "partial",
|
|
});
|
|
expect(next.channels?.telegram?.botToken).toBeUndefined();
|
|
expect(next.channels?.telegram?.allowFrom).toBeUndefined();
|
|
expect(next.channels?.telegram?.groupPolicy).toBeUndefined();
|
|
expect(next.channels?.telegram?.streaming).toBeUndefined();
|
|
expect(next.channels?.telegram?.accounts?.work?.botToken).toBe("work-token");
|
|
});
|
|
|
|
it("supports imessage/signal account-scoped channel patches", () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
signal: {
|
|
enabled: false,
|
|
accounts: {},
|
|
},
|
|
imessage: {
|
|
enabled: false,
|
|
},
|
|
},
|
|
};
|
|
|
|
const signalNext = patchChannelConfigForAccount({
|
|
cfg,
|
|
channel: "signal",
|
|
accountId: "work",
|
|
patch: { account: "+15555550123", cliPath: "signal-cli" },
|
|
});
|
|
expect(signalNext.channels?.signal?.enabled).toBe(true);
|
|
expect(signalNext.channels?.signal?.accounts?.work?.enabled).toBe(true);
|
|
expect(signalNext.channels?.signal?.accounts?.work?.account).toBe("+15555550123");
|
|
|
|
const imessageNext = patchChannelConfigForAccount({
|
|
cfg: signalNext,
|
|
channel: "imessage",
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
patch: { cliPath: "imsg" },
|
|
});
|
|
expect(imessageNext.channels?.imessage?.enabled).toBe(true);
|
|
expect(imessageNext.channels?.imessage?.cliPath).toBe("imsg");
|
|
});
|
|
});
|
|
|
|
describe("setOnboardingChannelEnabled", () => {
|
|
it("updates enabled and keeps existing channel fields", () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
discord: {
|
|
enabled: true,
|
|
token: "abc",
|
|
},
|
|
},
|
|
};
|
|
|
|
const next = setOnboardingChannelEnabled(cfg, "discord", false);
|
|
expect(next.channels?.discord?.enabled).toBe(false);
|
|
expect(next.channels?.discord?.token).toBe("abc");
|
|
});
|
|
|
|
it("creates missing channel config with enabled state", () => {
|
|
const next = setOnboardingChannelEnabled({}, "signal", true);
|
|
expect(next.channels?.signal?.enabled).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("patchLegacyDmChannelConfig", () => {
|
|
it("patches discord root config and defaults dm.enabled to true", () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
discord: {
|
|
dmPolicy: "pairing",
|
|
},
|
|
},
|
|
};
|
|
|
|
const next = patchLegacyDmChannelConfig({
|
|
cfg,
|
|
channel: "discord",
|
|
patch: { allowFrom: ["123"] },
|
|
});
|
|
expect(next.channels?.discord?.allowFrom).toEqual(["123"]);
|
|
expect(next.channels?.discord?.dm?.enabled).toBe(true);
|
|
});
|
|
|
|
it("preserves explicit dm.enabled=false for slack", () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
slack: {
|
|
dm: {
|
|
enabled: false,
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const next = patchLegacyDmChannelConfig({
|
|
cfg,
|
|
channel: "slack",
|
|
patch: { dmPolicy: "open" },
|
|
});
|
|
expect(next.channels?.slack?.dmPolicy).toBe("open");
|
|
expect(next.channels?.slack?.dm?.enabled).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("setLegacyChannelDmPolicyWithAllowFrom", () => {
|
|
it("adds wildcard allowFrom for open policy using legacy dm allowFrom fallback", () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
discord: {
|
|
dm: {
|
|
enabled: false,
|
|
allowFrom: ["123"],
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
const next = setLegacyChannelDmPolicyWithAllowFrom({
|
|
cfg,
|
|
channel: "discord",
|
|
dmPolicy: "open",
|
|
});
|
|
expect(next.channels?.discord?.dmPolicy).toBe("open");
|
|
expect(next.channels?.discord?.allowFrom).toEqual(["123", "*"]);
|
|
expect(next.channels?.discord?.dm?.enabled).toBe(false);
|
|
});
|
|
|
|
it("sets policy without changing allowFrom when not open", () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
slack: {
|
|
allowFrom: ["U1"],
|
|
},
|
|
},
|
|
};
|
|
|
|
const next = setLegacyChannelDmPolicyWithAllowFrom({
|
|
cfg,
|
|
channel: "slack",
|
|
dmPolicy: "pairing",
|
|
});
|
|
expect(next.channels?.slack?.dmPolicy).toBe("pairing");
|
|
expect(next.channels?.slack?.allowFrom).toEqual(["U1"]);
|
|
});
|
|
});
|
|
|
|
describe("setLegacyChannelAllowFrom", () => {
|
|
it("writes allowFrom through legacy dm patching", () => {
|
|
const next = setLegacyChannelAllowFrom({
|
|
cfg: {},
|
|
channel: "slack",
|
|
allowFrom: ["U123"],
|
|
});
|
|
expect(next.channels?.slack?.allowFrom).toEqual(["U123"]);
|
|
expect(next.channels?.slack?.dm?.enabled).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("setAccountGroupPolicyForChannel", () => {
|
|
it("writes group policy on default account config", () => {
|
|
const next = setAccountGroupPolicyForChannel({
|
|
cfg: {},
|
|
channel: "discord",
|
|
accountId: DEFAULT_ACCOUNT_ID,
|
|
groupPolicy: "open",
|
|
});
|
|
expect(next.channels?.discord?.groupPolicy).toBe("open");
|
|
expect(next.channels?.discord?.enabled).toBe(true);
|
|
});
|
|
|
|
it("writes group policy on nested non-default account", () => {
|
|
const next = setAccountGroupPolicyForChannel({
|
|
cfg: {},
|
|
channel: "slack",
|
|
accountId: "work",
|
|
groupPolicy: "disabled",
|
|
});
|
|
expect(next.channels?.slack?.accounts?.work?.groupPolicy).toBe("disabled");
|
|
expect(next.channels?.slack?.accounts?.work?.enabled).toBe(true);
|
|
});
|
|
});
|
|
|
|
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(["*"]);
|
|
});
|
|
|
|
it("supports telegram channel dmPolicy updates", () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
telegram: {
|
|
dmPolicy: "pairing",
|
|
allowFrom: ["123"],
|
|
},
|
|
},
|
|
};
|
|
|
|
const next = setChannelDmPolicyWithAllowFrom({
|
|
cfg,
|
|
channel: "telegram",
|
|
dmPolicy: "open",
|
|
});
|
|
expect(next.channels?.telegram?.dmPolicy).toBe("open");
|
|
expect(next.channels?.telegram?.allowFrom).toEqual(["123", "*"]);
|
|
});
|
|
});
|
|
|
|
describe("setTopLevelChannelDmPolicyWithAllowFrom", () => {
|
|
it("adds wildcard allowFrom for open policy", () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
zalo: {
|
|
dmPolicy: "pairing",
|
|
allowFrom: ["12345"],
|
|
},
|
|
},
|
|
};
|
|
|
|
const next = setTopLevelChannelDmPolicyWithAllowFrom({
|
|
cfg,
|
|
channel: "zalo",
|
|
dmPolicy: "open",
|
|
});
|
|
expect(next.channels?.zalo?.dmPolicy).toBe("open");
|
|
expect(next.channels?.zalo?.allowFrom).toEqual(["12345", "*"]);
|
|
});
|
|
|
|
it("supports custom allowFrom lookup callback", () => {
|
|
const cfg: OpenClawConfig = {
|
|
channels: {
|
|
"nextcloud-talk": {
|
|
dmPolicy: "pairing",
|
|
allowFrom: ["alice"],
|
|
},
|
|
},
|
|
};
|
|
|
|
const next = setTopLevelChannelDmPolicyWithAllowFrom({
|
|
cfg,
|
|
channel: "nextcloud-talk",
|
|
dmPolicy: "open",
|
|
getAllowFrom: (inputCfg) =>
|
|
normalizeAllowFromEntries(inputCfg.channels?.["nextcloud-talk"]?.allowFrom ?? []),
|
|
});
|
|
expect(next.channels?.["nextcloud-talk"]?.allowFrom).toEqual(["alice", "*"]);
|
|
});
|
|
});
|
|
|
|
describe("setTopLevelChannelAllowFrom", () => {
|
|
it("writes allowFrom and can force enabled state", () => {
|
|
const next = setTopLevelChannelAllowFrom({
|
|
cfg: {},
|
|
channel: "msteams",
|
|
allowFrom: ["user-1"],
|
|
enabled: true,
|
|
});
|
|
expect(next.channels?.msteams?.allowFrom).toEqual(["user-1"]);
|
|
expect(next.channels?.msteams?.enabled).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("setTopLevelChannelGroupPolicy", () => {
|
|
it("writes groupPolicy and can force enabled state", () => {
|
|
const next = setTopLevelChannelGroupPolicy({
|
|
cfg: {},
|
|
channel: "feishu",
|
|
groupPolicy: "allowlist",
|
|
enabled: true,
|
|
});
|
|
expect(next.channels?.feishu?.groupPolicy).toBe("allowlist");
|
|
expect(next.channels?.feishu?.enabled).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("splitOnboardingEntries", () => {
|
|
it("splits comma/newline/semicolon input and trims blanks", () => {
|
|
expect(splitOnboardingEntries(" alice, bob \ncarol; ;\n")).toEqual(["alice", "bob", "carol"]);
|
|
});
|
|
});
|
|
|
|
describe("parseOnboardingEntriesWithParser", () => {
|
|
it("maps entries and de-duplicates parsed values", () => {
|
|
expect(
|
|
parseOnboardingEntriesWithParser(" alice, ALICE ; * ", (entry) => {
|
|
if (entry === "*") {
|
|
return { value: "*" };
|
|
}
|
|
return { value: entry.toLowerCase() };
|
|
}),
|
|
).toEqual({
|
|
entries: ["alice", "*"],
|
|
});
|
|
});
|
|
|
|
it("returns parser errors and clears parsed entries", () => {
|
|
expect(
|
|
parseOnboardingEntriesWithParser("ok, bad", (entry) =>
|
|
entry === "bad" ? { error: "invalid entry: bad" } : { value: entry },
|
|
),
|
|
).toEqual({
|
|
entries: [],
|
|
error: "invalid entry: bad",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("parseOnboardingEntriesAllowingWildcard", () => {
|
|
it("preserves wildcard and delegates non-wildcard entries", () => {
|
|
expect(
|
|
parseOnboardingEntriesAllowingWildcard(" *, Foo ", (entry) => ({
|
|
value: entry.toLowerCase(),
|
|
})),
|
|
).toEqual({
|
|
entries: ["*", "foo"],
|
|
});
|
|
});
|
|
|
|
it("returns parser errors for non-wildcard entries", () => {
|
|
expect(
|
|
parseOnboardingEntriesAllowingWildcard("ok,bad", (entry) =>
|
|
entry === "bad" ? { error: "bad entry" } : { value: entry },
|
|
),
|
|
).toEqual({
|
|
entries: [],
|
|
error: "bad entry",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("parseMentionOrPrefixedId", () => {
|
|
it("parses mention ids", () => {
|
|
expect(
|
|
parseMentionOrPrefixedId({
|
|
value: "<@!123>",
|
|
mentionPattern: /^<@!?(\d+)>$/,
|
|
prefixPattern: /^(user:|discord:)/i,
|
|
idPattern: /^\d+$/,
|
|
}),
|
|
).toBe("123");
|
|
});
|
|
|
|
it("parses prefixed ids and normalizes result", () => {
|
|
expect(
|
|
parseMentionOrPrefixedId({
|
|
value: "slack:u123abc",
|
|
mentionPattern: /^<@([A-Z0-9]+)>$/i,
|
|
prefixPattern: /^(slack:|user:)/i,
|
|
idPattern: /^[A-Z][A-Z0-9]+$/i,
|
|
normalizeId: (id) => id.toUpperCase(),
|
|
}),
|
|
).toBe("U123ABC");
|
|
});
|
|
|
|
it("returns null for blank or invalid input", () => {
|
|
expect(
|
|
parseMentionOrPrefixedId({
|
|
value: " ",
|
|
mentionPattern: /^<@!?(\d+)>$/,
|
|
prefixPattern: /^(user:|discord:)/i,
|
|
idPattern: /^\d+$/,
|
|
}),
|
|
).toBeNull();
|
|
expect(
|
|
parseMentionOrPrefixedId({
|
|
value: "@alice",
|
|
mentionPattern: /^<@!?(\d+)>$/,
|
|
prefixPattern: /^(user:|discord:)/i,
|
|
idPattern: /^\d+$/,
|
|
}),
|
|
).toBeNull();
|
|
});
|
|
});
|
|
|
|
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",
|
|
}),
|
|
);
|
|
});
|
|
});
|