import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js"; import { applySingleTokenPromptResult, buildSingleChannelSecretPromptState, createAccountScopedAllowFromSection, createAccountScopedGroupAccessSection, createAllowFromSection, createLegacyCompatChannelDmPolicy, createNestedChannelAllowFromSetter, createNestedChannelDmPolicy, createNestedChannelDmPolicySetter, createTopLevelChannelAllowFromSetter, createTopLevelChannelDmPolicy, createTopLevelChannelDmPolicySetter, createTopLevelChannelGroupPolicySetter, normalizeAllowFromEntries, noteChannelLookupFailure, noteChannelLookupSummary, parseMentionOrPrefixedId, parseSetupEntriesAllowingWildcard, patchChannelConfigForAccount, patchNestedChannelConfigSection, patchLegacyDmChannelConfig, patchTopLevelChannelConfigSection, promptLegacyChannelAllowFrom, promptLegacyChannelAllowFromForAccount, promptParsedAllowFromForAccount, parseSetupEntriesWithParser, promptParsedAllowFromForScopedChannel, promptSingleChannelSecretInput, promptSingleChannelToken, promptResolvedAllowFrom, resolveAccountIdForConfigure, resolveEntriesWithOptionalToken, resolveGroupAllowlistWithLookupNotes, resolveParsedAllowFromEntries, resolveSetupAccountId, setAccountDmAllowFromForChannel, setAccountAllowFromForChannel, setAccountGroupPolicyForChannel, setChannelDmPolicyWithAllowFrom, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, setNestedChannelAllowFrom, setNestedChannelDmPolicyWithAllowFrom, setLegacyChannelAllowFrom, setLegacyChannelDmPolicyWithAllowFrom, setSetupChannelEnabled, splitSetupEntries, } from "./setup-wizard-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>; function asAllowFromResolver(resolveEntries: ReturnType): AllowFromResolver { return resolveEntries as AllowFromResolver; } async function runPromptResolvedAllowFromWithToken(params: { prompter: ReturnType; 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; 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; 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", () => { 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("promptLegacyChannelAllowFromForAccount", () => { it("resolves the account before delegating to the shared prompt flow", async () => { const prompter = createPrompter(["alice"]); const next = await promptLegacyChannelAllowFromForAccount({ cfg: { channels: { slack: { dm: { allowFrom: ["U0"], }, }, }, } as OpenClawConfig, channel: "slack", // oxlint-disable-next-line typescript/no-explicit-any prompter: prompter as any, defaultAccountId: DEFAULT_ACCOUNT_ID, resolveAccount: () => ({ botToken: "xoxb-token", dmAllowFrom: ["U0"], }), resolveExisting: (account) => account.dmAllowFrom, resolveToken: (account) => account.botToken, noteTitle: "Slack allowlist", noteLines: ["line"], message: "Slack allowFrom", placeholder: "@alice", parseId: () => null, invalidWithoutTokenNote: "need ids", resolveEntries: async ({ entries }) => entries.map((input) => ({ input, resolved: true, id: input.toUpperCase() })), }); expect(next.channels?.slack?.allowFrom).toEqual(["U0", "ALICE"]); expect(prompter.note).toHaveBeenCalledWith("line", "Slack allowlist"); }); }); 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) => parseSetupEntriesWithParser(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("promptParsedAllowFromForAccount", () => { it("applies parsed allowFrom values through the provided writer", async () => { const prompter = createPrompter(["Alice, ALICE"]); const next = await promptParsedAllowFromForAccount({ cfg: { channels: { bluebubbles: { accounts: { alt: { allowFrom: ["old"], }, }, }, }, } as OpenClawConfig, accountId: "alt", defaultAccountId: DEFAULT_ACCOUNT_ID, prompter, noteTitle: "BlueBubbles allowlist", noteLines: ["line"], message: "msg", placeholder: "placeholder", parseEntries: (raw) => parseSetupEntriesWithParser(raw, (entry) => ({ value: entry.toLowerCase() })), getExistingAllowFrom: ({ cfg, accountId }) => cfg.channels?.bluebubbles?.accounts?.[accountId]?.allowFrom ?? [], applyAllowFrom: ({ cfg, accountId, allowFrom }) => patchChannelConfigForAccount({ cfg, channel: "bluebubbles", accountId, patch: { allowFrom }, }), }); expect(next.channels?.bluebubbles?.accounts?.alt?.allowFrom).toEqual(["alice"]); expect(prompter.note).toHaveBeenCalledWith("line", "BlueBubbles allowlist"); }); it("can merge parsed values with existing entries", async () => { const next = await promptParsedAllowFromForAccount({ cfg: { channels: { nostr: { allowFrom: ["old"], }, }, } as OpenClawConfig, defaultAccountId: DEFAULT_ACCOUNT_ID, prompter: createPrompter(["new"]), noteTitle: "Nostr allowlist", noteLines: ["line"], message: "msg", placeholder: "placeholder", parseEntries: (raw) => ({ entries: [raw.trim()] }), getExistingAllowFrom: ({ cfg }) => cfg.channels?.nostr?.allowFrom ?? [], mergeEntries: ({ existing, parsed }) => [...existing.map(String), ...parsed], applyAllowFrom: ({ cfg, allowFrom }) => patchTopLevelChannelConfigSection({ cfg, channel: "nostr", patch: { allowFrom }, }), }); expect(next.channels?.nostr?.allowFrom).toEqual(["old", "new"]); }); }); 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("setSetupChannelEnabled", () => { it("updates enabled and keeps existing channel fields", () => { const cfg: OpenClawConfig = { channels: { discord: { enabled: true, token: "abc", }, }, }; const next = setSetupChannelEnabled(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 = setSetupChannelEnabled({}, "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("patchTopLevelChannelConfigSection", () => { it("clears requested fields before applying a patch", () => { const next = patchTopLevelChannelConfigSection({ cfg: { channels: { nostr: { privateKey: "nsec1", relays: ["wss://old.example"], }, }, }, channel: "nostr", clearFields: ["privateKey"], patch: { relays: ["wss://new.example"] }, enabled: true, }); expect(next.channels?.nostr?.privateKey).toBeUndefined(); expect(next.channels?.nostr?.relays).toEqual(["wss://new.example"]); expect(next.channels?.nostr?.enabled).toBe(true); }); }); describe("patchNestedChannelConfigSection", () => { it("clears requested nested fields before applying a patch", () => { const next = patchNestedChannelConfigSection({ cfg: { channels: { matrix: { dm: { policy: "pairing", allowFrom: ["@alice:example.org"], }, }, }, }, channel: "matrix", section: "dm", clearFields: ["allowFrom"], enabled: true, patch: { policy: "disabled" }, }); expect(next.channels?.matrix?.enabled).toBe(true); expect(next.channels?.matrix?.dm?.policy).toBe("disabled"); expect(next.channels?.matrix?.dm?.allowFrom).toBeUndefined(); }); }); describe("createTopLevelChannelDmPolicy", () => { it("creates a reusable dm policy definition", () => { const dmPolicy = createTopLevelChannelDmPolicy({ label: "LINE", channel: "line", policyKey: "channels.line.dmPolicy", allowFromKey: "channels.line.allowFrom", getCurrent: (cfg) => cfg.channels?.line?.dmPolicy ?? "pairing", }); const next = dmPolicy.setPolicy( { channels: { line: { dmPolicy: "pairing", allowFrom: ["U123"], }, }, }, "open", ); expect(dmPolicy.getCurrent({})).toBe("pairing"); expect(next.channels?.line?.dmPolicy).toBe("open"); expect(next.channels?.line?.allowFrom).toEqual(["U123", "*"]); }); }); describe("createTopLevelChannelDmPolicySetter", () => { it("reuses the shared top-level dmPolicy writer", () => { const setPolicy = createTopLevelChannelDmPolicySetter({ channel: "zalo", }); const next = setPolicy( { channels: { zalo: { allowFrom: ["12345"], }, }, }, "open", ); expect(next.channels?.zalo?.dmPolicy).toBe("open"); expect(next.channels?.zalo?.allowFrom).toEqual(["12345", "*"]); }); }); describe("setNestedChannelAllowFrom", () => { it("writes nested allowFrom and can force enabled state", () => { const next = setNestedChannelAllowFrom({ cfg: {}, channel: "googlechat", section: "dm", allowFrom: ["users/123"], enabled: true, }); expect(next.channels?.googlechat?.enabled).toBe(true); expect(next.channels?.googlechat?.dm?.allowFrom).toEqual(["users/123"]); }); }); describe("setNestedChannelDmPolicyWithAllowFrom", () => { it("adds wildcard allowFrom for open policy inside a nested section", () => { const next = setNestedChannelDmPolicyWithAllowFrom({ cfg: { channels: { matrix: { dm: { policy: "pairing", allowFrom: ["@alice:example.org"], }, }, }, }, channel: "matrix", section: "dm", dmPolicy: "open", enabled: true, }); expect(next.channels?.matrix?.enabled).toBe(true); expect(next.channels?.matrix?.dm?.policy).toBe("open"); expect(next.channels?.matrix?.dm?.allowFrom).toEqual(["@alice:example.org", "*"]); }); }); describe("createNestedChannelDmPolicy", () => { it("creates a reusable nested dm policy definition", () => { const dmPolicy = createNestedChannelDmPolicy({ label: "Matrix", channel: "matrix", section: "dm", policyKey: "channels.matrix.dm.policy", allowFromKey: "channels.matrix.dm.allowFrom", getCurrent: (cfg) => cfg.channels?.matrix?.dm?.policy ?? "pairing", enabled: true, }); const next = dmPolicy.setPolicy( { channels: { matrix: { dm: { allowFrom: ["@alice:example.org"], }, }, }, }, "open", ); expect(next.channels?.matrix?.enabled).toBe(true); expect(next.channels?.matrix?.dm?.policy).toBe("open"); expect(next.channels?.matrix?.dm?.allowFrom).toEqual(["@alice:example.org", "*"]); }); }); describe("createNestedChannelDmPolicySetter", () => { it("reuses the shared nested dmPolicy writer", () => { const setPolicy = createNestedChannelDmPolicySetter({ channel: "googlechat", section: "dm", enabled: true, }); const next = setPolicy({}, "disabled"); expect(next.channels?.googlechat?.enabled).toBe(true); expect(next.channels?.googlechat?.dm?.policy).toBe("disabled"); }); }); describe("createNestedChannelAllowFromSetter", () => { it("reuses the shared nested allowFrom writer", () => { const setAllowFrom = createNestedChannelAllowFromSetter({ channel: "googlechat", section: "dm", enabled: true, }); const next = setAllowFrom({}, ["users/123"]); expect(next.channels?.googlechat?.enabled).toBe(true); expect(next.channels?.googlechat?.dm?.allowFrom).toEqual(["users/123"]); }); }); describe("createTopLevelChannelAllowFromSetter", () => { it("reuses the shared top-level allowFrom writer", () => { const setAllowFrom = createTopLevelChannelAllowFromSetter({ channel: "msteams", enabled: true, }); const next = setAllowFrom({}, ["user-1"]); expect(next.channels?.msteams?.allowFrom).toEqual(["user-1"]); expect(next.channels?.msteams?.enabled).toBe(true); }); }); describe("createLegacyCompatChannelDmPolicy", () => { it("reads nested legacy dm policy and writes top-level compat fields", () => { const dmPolicy = createLegacyCompatChannelDmPolicy({ label: "Slack", channel: "slack", }); expect( dmPolicy.getCurrent({ channels: { slack: { dm: { policy: "open", }, }, }, }), ).toBe("open"); const next = dmPolicy.setPolicy( { channels: { slack: { dm: { allowFrom: ["U123"], }, }, }, }, "open", ); expect(next.channels?.slack?.dmPolicy).toBe("open"); expect(next.channels?.slack?.allowFrom).toEqual(["U123", "*"]); }); }); describe("createTopLevelChannelGroupPolicySetter", () => { it("reuses the shared top-level groupPolicy writer", () => { const setGroupPolicy = createTopLevelChannelGroupPolicySetter({ channel: "feishu", enabled: true, }); const next = setGroupPolicy({}, "allowlist"); expect(next.channels?.feishu?.groupPolicy).toBe("allowlist"); expect(next.channels?.feishu?.enabled).toBe(true); }); }); describe("setAccountDmAllowFromForChannel", () => { it("writes account-scoped allowlist dm config", () => { const next = setAccountDmAllowFromForChannel({ cfg: {}, channel: "discord", accountId: DEFAULT_ACCOUNT_ID, allowFrom: ["123"], }); expect(next.channels?.discord?.dmPolicy).toBe("allowlist"); expect(next.channels?.discord?.allowFrom).toEqual(["123"]); }); }); describe("resolveGroupAllowlistWithLookupNotes", () => { it("returns resolved values when lookup succeeds", async () => { const prompter = createPrompter([]); await expect( resolveGroupAllowlistWithLookupNotes({ label: "Discord channels", prompter, entries: ["general"], fallback: [], resolve: async () => ["guild/channel"], }), ).resolves.toEqual(["guild/channel"]); expect(prompter.note).not.toHaveBeenCalled(); }); it("notes lookup failure and returns the fallback", async () => { const prompter = createPrompter([]); await expect( resolveGroupAllowlistWithLookupNotes({ label: "Slack channels", prompter, entries: ["general"], fallback: ["general"], resolve: async () => { throw new Error("boom"); }, }), ).resolves.toEqual(["general"]); expect(prompter.note).toHaveBeenCalledTimes(2); }); }); describe("createAccountScopedAllowFromSection", () => { it("builds an account-scoped allowFrom section with shared apply wiring", async () => { const section = createAccountScopedAllowFromSection({ channel: "discord", credentialInputKey: "token", message: "Discord allowFrom", placeholder: "@alice", invalidWithoutCredentialNote: "need ids", parseId: (value) => value.trim() || null, resolveEntries: async ({ entries }) => entries.map((input) => ({ input, resolved: true, id: input.toUpperCase() })), }); expect(section.credentialInputKey).toBe("token"); await expect( section.resolveEntries({ cfg: {}, accountId: DEFAULT_ACCOUNT_ID, credentialValues: {}, entries: ["alice"], }), ).resolves.toEqual([{ input: "alice", resolved: true, id: "ALICE" }]); const next = await section.apply({ cfg: {}, accountId: DEFAULT_ACCOUNT_ID, allowFrom: ["123"], }); expect(next.channels?.discord?.dmPolicy).toBe("allowlist"); expect(next.channels?.discord?.allowFrom).toEqual(["123"]); }); }); describe("createAllowFromSection", () => { it("builds a parsed allowFrom section with default local resolution", async () => { const section = createAllowFromSection({ helpTitle: "LINE allowlist", helpLines: ["line"], credentialInputKey: "token", message: "LINE allowFrom", placeholder: "U123", invalidWithoutCredentialNote: "need ids", parseId: (value) => value.trim().toUpperCase() || null, apply: ({ cfg, accountId, allowFrom }) => patchChannelConfigForAccount({ cfg, channel: "line", accountId, patch: { dmPolicy: "allowlist", allowFrom }, }), }); expect(section.helpTitle).toBe("LINE allowlist"); await expect( section.resolveEntries({ cfg: {}, accountId: DEFAULT_ACCOUNT_ID, credentialValues: {}, entries: ["u1"], }), ).resolves.toEqual([{ input: "u1", resolved: true, id: "U1" }]); const next = await section.apply({ cfg: {}, accountId: DEFAULT_ACCOUNT_ID, allowFrom: ["U1"], }); expect(next.channels?.line?.allowFrom).toEqual(["U1"]); }); }); describe("createAccountScopedGroupAccessSection", () => { it("builds group access with shared setPolicy and fallback lookup notes", async () => { const prompter = createPrompter([]); const section = createAccountScopedGroupAccessSection({ channel: "slack", label: "Slack channels", placeholder: "#general", currentPolicy: () => "allowlist", currentEntries: () => [], updatePrompt: () => false, resolveAllowlist: async () => { throw new Error("boom"); }, fallbackResolved: (entries) => entries, applyAllowlist: ({ cfg, resolved, accountId }) => patchChannelConfigForAccount({ cfg, channel: "slack", accountId, patch: { channels: Object.fromEntries(resolved.map((entry) => [entry, { allow: true }])), }, }), }); const policyNext = section.setPolicy({ cfg: {}, accountId: DEFAULT_ACCOUNT_ID, policy: "open", }); expect(policyNext.channels?.slack?.groupPolicy).toBe("open"); await expect( section.resolveAllowlist?.({ cfg: {}, accountId: DEFAULT_ACCOUNT_ID, credentialValues: {}, entries: ["general"], prompter, }), ).resolves.toEqual(["general"]); expect(prompter.note).toHaveBeenCalledTimes(2); const allowlistNext = section.applyAllowlist?.({ cfg: {}, accountId: DEFAULT_ACCOUNT_ID, resolved: ["C123"], }); expect(allowlistNext?.channels?.slack?.channels).toEqual({ C123: { allow: true }, }); }); }); describe("splitSetupEntries", () => { it("splits comma/newline/semicolon input and trims blanks", () => { expect(splitSetupEntries(" alice, bob \ncarol; ;\n")).toEqual(["alice", "bob", "carol"]); }); }); describe("parseSetupEntriesWithParser", () => { it("maps entries and de-duplicates parsed values", () => { expect( parseSetupEntriesWithParser(" alice, ALICE ; * ", (entry) => { if (entry === "*") { return { value: "*" }; } return { value: entry.toLowerCase() }; }), ).toEqual({ entries: ["alice", "*"], }); }); it("returns parser errors and clears parsed entries", () => { expect( parseSetupEntriesWithParser("ok, bad", (entry) => entry === "bad" ? { error: "invalid entry: bad" } : { value: entry }, ), ).toEqual({ entries: [], error: "invalid entry: bad", }); }); }); describe("parseSetupEntriesAllowingWildcard", () => { it("preserves wildcard and delegates non-wildcard entries", () => { expect( parseSetupEntriesAllowingWildcard(" *, Foo ", (entry) => ({ value: entry.toLowerCase(), })), ).toEqual({ entries: ["*", "foo"], }); }); it("returns parser errors for non-wildcard entries", () => { expect( parseSetupEntriesAllowingWildcard("ok,bad", (entry) => entry === "bad" ? { error: "bad entry" } : { value: entry }, ), ).toEqual({ entries: [], error: "bad entry", }); }); }); describe("resolveEntriesWithOptionalToken", () => { it("returns unresolved entries when token is missing", async () => { await expect( resolveEntriesWithOptionalToken({ entries: ["alice", "bob"], buildWithoutToken: (input) => ({ input, resolved: false, id: null }), resolveEntries: async () => { throw new Error("should not run"); }, }), ).resolves.toEqual([ { input: "alice", resolved: false, id: null }, { input: "bob", resolved: false, id: null }, ]); }); it("delegates to the resolver when token exists", async () => { await expect( resolveEntriesWithOptionalToken<{ input: string; resolved: boolean; id: string | null; }>({ token: "xoxb-test", entries: ["alice"], buildWithoutToken: (input) => ({ input, resolved: false, id: null }), resolveEntries: async ({ token, entries }) => entries.map((input) => ({ input, resolved: true, id: `${token}:${input}` })), }), ).resolves.toEqual([{ input: "alice", resolved: true, id: "xoxb-test:alice" }]); }); }); describe("resolveParsedAllowFromEntries", () => { it("maps parsed ids into resolved/unresolved entries", () => { expect( resolveParsedAllowFromEntries({ entries: ["alice", " "], parseId: (raw) => raw.trim() || null, }), ).toEqual([ { input: "alice", resolved: true, id: "alice" }, { input: " ", resolved: false, id: null }, ]); }); }); 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("resolveSetupAccountId", () => { it("normalizes provided account ids", () => { expect( resolveSetupAccountId({ accountId: " Work Account ", defaultAccountId: DEFAULT_ACCOUNT_ID, }), ).toBe("work-account"); }); it("falls back to default account id when input is blank", () => { expect( resolveSetupAccountId({ accountId: " ", defaultAccountId: "custom-default", }), ).toBe("custom-default"); }); }); describe("resolveAccountIdForConfigure", () => { 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 () => { const prompter = { select: vi.fn(async () => "prompted-id"), text: vi.fn(async () => ""), note: vi.fn(async () => undefined), }; const accountId = await resolveAccountIdForConfigure({ cfg: {}, // oxlint-disable-next-line typescript/no-explicit-any prompter: prompter as any, label: "Signal", shouldPromptAccountIds: true, listAccountIds: () => ["default", "prompted-id"], defaultAccountId: "fallback", }); expect(accountId).toBe("prompted-id"); expect(prompter.select).toHaveBeenCalledWith( expect.objectContaining({ message: "Signal account", initialValue: "fallback", }), ); expect(prompter.text).not.toHaveBeenCalled(); }); });