diff --git a/extensions/telegram/src/allow-from.test.ts b/extensions/telegram/src/allow-from.test.ts deleted file mode 100644 index 83801d558f7..00000000000 --- a/extensions/telegram/src/allow-from.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { isNumericTelegramUserId, normalizeTelegramAllowFromEntry } from "./allow-from.js"; - -describe("telegram allow-from helpers", () => { - it("normalizes tg/telegram prefixes", () => { - const cases = [ - { value: " TG:123 ", expected: "123" }, - { value: "telegram:@someone", expected: "@someone" }, - ] as const; - for (const testCase of cases) { - expect(normalizeTelegramAllowFromEntry(testCase.value)).toBe(testCase.expected); - } - }); - - it("accepts signed numeric IDs", () => { - const cases = [ - { value: "123456789", expected: true }, - { value: "-1001234567890", expected: true }, - { value: "@someone", expected: false }, - { value: "12 34", expected: false }, - ] as const; - for (const testCase of cases) { - expect(isNumericTelegramUserId(testCase.value)).toBe(testCase.expected); - } - }); -}); diff --git a/extensions/telegram/src/normalize.test.ts b/extensions/telegram/src/normalize.test.ts deleted file mode 100644 index bbd0afbae6f..00000000000 --- a/extensions/telegram/src/normalize.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./normalize.js"; - -describe("telegram target normalization", () => { - it("normalizes telegram prefixes, group targets, and topic suffixes", () => { - expect(normalizeTelegramMessagingTarget("telegram:123456")).toBe("telegram:123456"); - expect(normalizeTelegramMessagingTarget("tg:group:-100123")).toBe("telegram:group:-100123"); - expect(normalizeTelegramMessagingTarget("telegram:-100123:topic:99")).toBe( - "telegram:-100123:topic:99", - ); - }); - - it("returns undefined for invalid telegram recipients", () => { - expect(normalizeTelegramMessagingTarget("telegram:")).toBeUndefined(); - expect(normalizeTelegramMessagingTarget(" ")).toBeUndefined(); - }); - - it("detects valid telegram target identifiers", () => { - expect(looksLikeTelegramTargetId("telegram:123456")).toBe(true); - expect(looksLikeTelegramTargetId("tg:group:-100123")).toBe(true); - expect(looksLikeTelegramTargetId("hello world")).toBe(false); - }); -}); diff --git a/extensions/telegram/src/setup-core.test.ts b/extensions/telegram/src/setup-core.test.ts deleted file mode 100644 index 5cf316c54d6..00000000000 --- a/extensions/telegram/src/setup-core.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { resolveTelegramAllowFromEntries } from "./setup-core.js"; - -describe("resolveTelegramAllowFromEntries", () => { - it("passes apiRoot through username lookups", async () => { - const globalFetch = vi.fn(async () => { - throw new Error("global fetch should not be called"); - }); - const fetchMock = vi.fn(async () => ({ - ok: true, - json: async () => ({ ok: true, result: { id: 12345 } }), - })); - vi.stubGlobal("fetch", globalFetch); - const proxyFetch = vi.fn(); - const fetchModule = await import("./fetch.js"); - const proxyModule = await import("./proxy.js"); - const resolveTelegramFetch = vi.spyOn(fetchModule, "resolveTelegramFetch"); - const makeProxyFetch = vi.spyOn(proxyModule, "makeProxyFetch"); - makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch); - resolveTelegramFetch.mockReturnValue(fetchMock as unknown as typeof fetch); - - try { - const resolved = await resolveTelegramAllowFromEntries({ - entries: ["@user"], - credentialValue: "tok", - apiRoot: "https://custom.telegram.test/root/", - proxyUrl: "http://127.0.0.1:8080", - network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" }, - }); - - expect(resolved).toEqual([{ input: "@user", resolved: true, id: "12345" }]); - expect(makeProxyFetch).toHaveBeenCalledWith("http://127.0.0.1:8080"); - expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { - network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" }, - }); - expect(fetchMock).toHaveBeenCalledWith( - "https://custom.telegram.test/root/bottok/getChat?chat_id=%40user", - undefined, - ); - } finally { - makeProxyFetch.mockRestore(); - resolveTelegramFetch.mockRestore(); - vi.unstubAllGlobals(); - } - }); -}); diff --git a/extensions/telegram/src/setup-surface.test.ts b/extensions/telegram/src/setup-surface.test.ts index 722f5cedca2..a218af37ceb 100644 --- a/extensions/telegram/src/setup-surface.test.ts +++ b/extensions/telegram/src/setup-surface.test.ts @@ -6,6 +6,7 @@ import { runSetupWizardFinalize, runSetupWizardPrepare, } from "../../../test/helpers/extensions/setup-wizard.js"; +import { resolveTelegramAllowFromEntries } from "./setup-core.js"; import { telegramSetupWizard } from "./setup-surface.js"; async function runPrepare(cfg: OpenClawConfig, accountId: string) { @@ -160,3 +161,47 @@ describe("telegramSetupWizard.finalize", () => { expect(note).not.toHaveBeenCalled(); }); }); + +describe("resolveTelegramAllowFromEntries", () => { + it("passes apiRoot through username lookups", async () => { + const globalFetch = vi.fn(async () => { + throw new Error("global fetch should not be called"); + }); + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ ok: true, result: { id: 12345 } }), + })); + vi.stubGlobal("fetch", globalFetch); + const proxyFetch = vi.fn(); + const fetchModule = await import("./fetch.js"); + const proxyModule = await import("./proxy.js"); + const resolveTelegramFetch = vi.spyOn(fetchModule, "resolveTelegramFetch"); + const makeProxyFetch = vi.spyOn(proxyModule, "makeProxyFetch"); + makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch); + resolveTelegramFetch.mockReturnValue(fetchMock as unknown as typeof fetch); + + try { + const resolved = await resolveTelegramAllowFromEntries({ + entries: ["@user"], + credentialValue: "tok", + apiRoot: "https://custom.telegram.test/root/", + proxyUrl: "http://127.0.0.1:8080", + network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" }, + }); + + expect(resolved).toEqual([{ input: "@user", resolved: true, id: "12345" }]); + expect(makeProxyFetch).toHaveBeenCalledWith("http://127.0.0.1:8080"); + expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { + network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" }, + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://custom.telegram.test/root/bottok/getChat?chat_id=%40user", + undefined, + ); + } finally { + makeProxyFetch.mockRestore(); + resolveTelegramFetch.mockRestore(); + vi.unstubAllGlobals(); + } + }); +}); diff --git a/extensions/telegram/src/target-writeback.test.ts b/extensions/telegram/src/target-writeback.test.ts deleted file mode 100644 index 8403f7e1b0f..00000000000 --- a/extensions/telegram/src/target-writeback.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../src/config/config.js"; - -const readConfigFileSnapshotForWrite = vi.fn(); -const writeConfigFile = vi.fn(); -const loadCronStore = vi.fn(); -const resolveCronStorePath = vi.fn(); -const saveCronStore = vi.fn(); - -vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - readConfigFileSnapshotForWrite, - writeConfigFile, - loadCronStore, - resolveCronStorePath, - saveCronStore, - }; -}); - -describe("maybePersistResolvedTelegramTarget", () => { - let maybePersistResolvedTelegramTarget: typeof import("./target-writeback.js").maybePersistResolvedTelegramTarget; - - beforeEach(async () => { - vi.resetModules(); - ({ maybePersistResolvedTelegramTarget } = await import("./target-writeback.js")); - readConfigFileSnapshotForWrite.mockReset(); - writeConfigFile.mockReset(); - loadCronStore.mockReset(); - resolveCronStorePath.mockReset(); - saveCronStore.mockReset(); - resolveCronStorePath.mockReturnValue("/tmp/cron/jobs.json"); - }); - - it("skips writeback when target is already numeric", async () => { - await maybePersistResolvedTelegramTarget({ - cfg: {} as OpenClawConfig, - rawTarget: "-100123", - resolvedChatId: "-100123", - }); - - expect(readConfigFileSnapshotForWrite).not.toHaveBeenCalled(); - expect(loadCronStore).not.toHaveBeenCalled(); - }); - - it("writes back matching config and cron targets", async () => { - readConfigFileSnapshotForWrite.mockResolvedValue({ - snapshot: { - config: { - channels: { - telegram: { - defaultTo: "t.me/mychannel", - accounts: { - alerts: { - defaultTo: "@mychannel", - }, - }, - }, - }, - }, - }, - writeOptions: { expectedConfigPath: "/tmp/openclaw.json" }, - }); - loadCronStore.mockResolvedValue({ - version: 1, - jobs: [ - { id: "a", delivery: { channel: "telegram", to: "https://t.me/mychannel" } }, - { id: "b", delivery: { channel: "slack", to: "C123" } }, - ], - }); - - await maybePersistResolvedTelegramTarget({ - cfg: { - cron: { store: "/tmp/cron/jobs.json" }, - } as OpenClawConfig, - rawTarget: "t.me/mychannel", - resolvedChatId: "-100123", - }); - - expect(writeConfigFile).toHaveBeenCalledTimes(1); - expect(writeConfigFile).toHaveBeenCalledWith( - expect.objectContaining({ - channels: { - telegram: { - defaultTo: "-100123", - accounts: { - alerts: { - defaultTo: "-100123", - }, - }, - }, - }, - }), - expect.objectContaining({ expectedConfigPath: "/tmp/openclaw.json" }), - ); - expect(saveCronStore).toHaveBeenCalledTimes(1); - expect(saveCronStore).toHaveBeenCalledWith( - "/tmp/cron/jobs.json", - expect.objectContaining({ - jobs: [ - { id: "a", delivery: { channel: "telegram", to: "-100123" } }, - { id: "b", delivery: { channel: "slack", to: "C123" } }, - ], - }), - ); - }); - - it("preserves topic suffix style in writeback target", async () => { - readConfigFileSnapshotForWrite.mockResolvedValue({ - snapshot: { - config: { - channels: { - telegram: { - defaultTo: "t.me/mychannel:topic:9", - }, - }, - }, - }, - writeOptions: {}, - }); - loadCronStore.mockResolvedValue({ version: 1, jobs: [] }); - - await maybePersistResolvedTelegramTarget({ - cfg: {} as OpenClawConfig, - rawTarget: "t.me/mychannel:topic:9", - resolvedChatId: "-100123", - }); - - expect(writeConfigFile).toHaveBeenCalledWith( - expect.objectContaining({ - channels: { - telegram: { - defaultTo: "-100123:topic:9", - }, - }, - }), - expect.any(Object), - ); - }); - - it("matches username targets case-insensitively", async () => { - readConfigFileSnapshotForWrite.mockResolvedValue({ - snapshot: { - config: { - channels: { - telegram: { - defaultTo: "https://t.me/mychannel", - }, - }, - }, - }, - writeOptions: {}, - }); - loadCronStore.mockResolvedValue({ - version: 1, - jobs: [{ id: "a", delivery: { channel: "telegram", to: "https://t.me/mychannel" } }], - }); - - await maybePersistResolvedTelegramTarget({ - cfg: {} as OpenClawConfig, - rawTarget: "@MyChannel", - resolvedChatId: "-100123", - }); - - expect(writeConfigFile).toHaveBeenCalledWith( - expect.objectContaining({ - channels: { - telegram: { - defaultTo: "-100123", - }, - }, - }), - expect.any(Object), - ); - expect(saveCronStore).toHaveBeenCalledWith( - "/tmp/cron/jobs.json", - expect.objectContaining({ - jobs: [{ id: "a", delivery: { channel: "telegram", to: "-100123" } }], - }), - ); - }); -}); diff --git a/extensions/telegram/src/targets.test.ts b/extensions/telegram/src/targets.test.ts index 22541fd0376..0761066cf91 100644 --- a/extensions/telegram/src/targets.test.ts +++ b/extensions/telegram/src/targets.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { isNumericTelegramUserId, normalizeTelegramAllowFromEntry } from "./allow-from.js"; import { resolveTelegramGroupRequireMention, resolveTelegramGroupToolPolicy, } from "./group-policy.js"; +import { looksLikeTelegramTargetId, normalizeTelegramMessagingTarget } from "./normalize.js"; import { isNumericTelegramChatId, normalizeTelegramChatId, @@ -11,6 +14,24 @@ import { stripTelegramInternalPrefixes, } from "./targets.js"; +const readConfigFileSnapshotForWrite = vi.fn(); +const writeConfigFile = vi.fn(); +const loadCronStore = vi.fn(); +const resolveCronStorePath = vi.fn(); +const saveCronStore = vi.fn(); + +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + readConfigFileSnapshotForWrite, + writeConfigFile, + loadCronStore, + resolveCronStorePath, + saveCronStore, + }; +}); + describe("stripTelegramInternalPrefixes", () => { it("strips telegram prefix", () => { expect(stripTelegramInternalPrefixes("telegram:123")).toBe("123"); @@ -168,3 +189,211 @@ describe("telegram group policy", () => { ); }); }); + +describe("telegram allow-from helpers", () => { + it("normalizes tg/telegram prefixes", () => { + const cases = [ + { value: " TG:123 ", expected: "123" }, + { value: "telegram:@someone", expected: "@someone" }, + ] as const; + for (const testCase of cases) { + expect(normalizeTelegramAllowFromEntry(testCase.value)).toBe(testCase.expected); + } + }); + + it("accepts signed numeric IDs", () => { + const cases = [ + { value: "123456789", expected: true }, + { value: "-1001234567890", expected: true }, + { value: "@someone", expected: false }, + { value: "12 34", expected: false }, + ] as const; + for (const testCase of cases) { + expect(isNumericTelegramUserId(testCase.value)).toBe(testCase.expected); + } + }); +}); + +describe("telegram target normalization", () => { + it("normalizes telegram prefixes, group targets, and topic suffixes", () => { + expect(normalizeTelegramMessagingTarget("telegram:123456")).toBe("telegram:123456"); + expect(normalizeTelegramMessagingTarget("tg:group:-100123")).toBe("telegram:group:-100123"); + expect(normalizeTelegramMessagingTarget("telegram:-100123:topic:99")).toBe( + "telegram:-100123:topic:99", + ); + }); + + it("returns undefined for invalid telegram recipients", () => { + expect(normalizeTelegramMessagingTarget("telegram:")).toBeUndefined(); + expect(normalizeTelegramMessagingTarget(" ")).toBeUndefined(); + }); + + it("detects valid telegram target identifiers", () => { + expect(looksLikeTelegramTargetId("telegram:123456")).toBe(true); + expect(looksLikeTelegramTargetId("tg:group:-100123")).toBe(true); + expect(looksLikeTelegramTargetId("hello world")).toBe(false); + }); +}); + +describe("maybePersistResolvedTelegramTarget", () => { + let maybePersistResolvedTelegramTarget: typeof import("./target-writeback.js").maybePersistResolvedTelegramTarget; + + beforeEach(async () => { + vi.resetModules(); + ({ maybePersistResolvedTelegramTarget } = await import("./target-writeback.js")); + readConfigFileSnapshotForWrite.mockReset(); + writeConfigFile.mockReset(); + loadCronStore.mockReset(); + resolveCronStorePath.mockReset(); + saveCronStore.mockReset(); + resolveCronStorePath.mockReturnValue("/tmp/cron/jobs.json"); + }); + + it("skips writeback when target is already numeric", async () => { + await maybePersistResolvedTelegramTarget({ + cfg: {} as OpenClawConfig, + rawTarget: "-100123", + resolvedChatId: "-100123", + }); + + expect(readConfigFileSnapshotForWrite).not.toHaveBeenCalled(); + expect(loadCronStore).not.toHaveBeenCalled(); + }); + + it("writes back matching config and cron targets", async () => { + readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { + config: { + channels: { + telegram: { + defaultTo: "t.me/mychannel", + accounts: { + alerts: { + defaultTo: "@mychannel", + }, + }, + }, + }, + }, + }, + writeOptions: { expectedConfigPath: "/tmp/openclaw.json" }, + }); + loadCronStore.mockResolvedValue({ + version: 1, + jobs: [ + { id: "a", delivery: { channel: "telegram", to: "https://t.me/mychannel" } }, + { id: "b", delivery: { channel: "slack", to: "C123" } }, + ], + }); + + await maybePersistResolvedTelegramTarget({ + cfg: { + cron: { store: "/tmp/cron/jobs.json" }, + } as OpenClawConfig, + rawTarget: "t.me/mychannel", + resolvedChatId: "-100123", + }); + + expect(writeConfigFile).toHaveBeenCalledTimes(1); + expect(writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + channels: { + telegram: { + defaultTo: "-100123", + accounts: { + alerts: { + defaultTo: "-100123", + }, + }, + }, + }, + }), + expect.objectContaining({ expectedConfigPath: "/tmp/openclaw.json" }), + ); + expect(saveCronStore).toHaveBeenCalledTimes(1); + expect(saveCronStore).toHaveBeenCalledWith( + "/tmp/cron/jobs.json", + expect.objectContaining({ + jobs: [ + { id: "a", delivery: { channel: "telegram", to: "-100123" } }, + { id: "b", delivery: { channel: "slack", to: "C123" } }, + ], + }), + ); + }); + + it("preserves topic suffix style in writeback target", async () => { + readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { + config: { + channels: { + telegram: { + defaultTo: "t.me/mychannel:topic:9", + }, + }, + }, + }, + writeOptions: {}, + }); + loadCronStore.mockResolvedValue({ version: 1, jobs: [] }); + + await maybePersistResolvedTelegramTarget({ + cfg: {} as OpenClawConfig, + rawTarget: "t.me/mychannel:topic:9", + resolvedChatId: "-100123", + }); + + expect(writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + channels: { + telegram: { + defaultTo: "-100123:topic:9", + }, + }, + }), + expect.any(Object), + ); + }); + + it("matches username targets case-insensitively", async () => { + readConfigFileSnapshotForWrite.mockResolvedValue({ + snapshot: { + config: { + channels: { + telegram: { + defaultTo: "https://t.me/mychannel", + }, + }, + }, + }, + writeOptions: {}, + }); + loadCronStore.mockResolvedValue({ + version: 1, + jobs: [{ id: "a", delivery: { channel: "telegram", to: "https://t.me/mychannel" } }], + }); + + await maybePersistResolvedTelegramTarget({ + cfg: {} as OpenClawConfig, + rawTarget: "@MyChannel", + resolvedChatId: "-100123", + }); + + expect(writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + channels: { + telegram: { + defaultTo: "-100123", + }, + }, + }), + expect.any(Object), + ); + expect(saveCronStore).toHaveBeenCalledWith( + "/tmp/cron/jobs.json", + expect.objectContaining({ + jobs: [{ id: "a", delivery: { channel: "telegram", to: "-100123" } }], + }), + ); + }); +}); diff --git a/extensions/whatsapp/src/channel.test.ts b/extensions/whatsapp/src/channel.test.ts index a9f3b9cfee4..0ca02c46cb6 100644 --- a/extensions/whatsapp/src/channel.test.ts +++ b/extensions/whatsapp/src/channel.test.ts @@ -1,4 +1,6 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import type { RuntimeEnv } from "../../../src/runtime.js"; import { createWhatsAppPollFixture, expectWhatsAppPollSent, @@ -7,6 +9,11 @@ import { createDirectoryTestRuntime, expectDirectorySurface, } from "../../../test/helpers/extensions/directory.ts"; +import { + createPluginSetupWizardConfigure, + createQueuedWizardPrompter, + runSetupWizardConfigure, +} from "../../../test/helpers/extensions/setup-wizard.js"; import { whatsappPlugin } from "./channel.js"; import { resolveWhatsAppGroupRequireMention, @@ -16,6 +23,13 @@ import type { OpenClawConfig } from "./runtime-api.js"; const hoisted = vi.hoisted(() => ({ sendPollWhatsApp: vi.fn(async () => ({ messageId: "wa-poll-1", toJid: "1555@s.whatsapp.net" })), + loginWeb: vi.fn(async () => {}), + pathExists: vi.fn(async () => false), + listWhatsAppAccountIds: vi.fn(() => [] as string[]), + resolveDefaultWhatsAppAccountId: vi.fn(() => DEFAULT_ACCOUNT_ID), + resolveWhatsAppAuthDir: vi.fn(() => ({ + authDir: "/tmp/openclaw-whatsapp-test", + })), })); vi.mock("./runtime.js", () => ({ @@ -31,6 +45,79 @@ vi.mock("./runtime.js", () => ({ }), })); +vi.mock("./login.js", () => ({ + loginWeb: hoisted.loginWeb, +})); + +vi.mock("openclaw/plugin-sdk/setup", async () => { + const actual = await vi.importActual( + "openclaw/plugin-sdk/setup", + ); + return { + ...actual, + pathExists: hoisted.pathExists, + }; +}); + +vi.mock("./accounts.js", async () => { + const actual = await vi.importActual("./accounts.js"); + return { + ...actual, + listWhatsAppAccountIds: hoisted.listWhatsAppAccountIds, + resolveDefaultWhatsAppAccountId: hoisted.resolveDefaultWhatsAppAccountId, + resolveWhatsAppAuthDir: hoisted.resolveWhatsAppAuthDir, + }; +}); + +function createRuntime(): RuntimeEnv { + return { + error: vi.fn(), + } as unknown as RuntimeEnv; +} + +let whatsappConfigure: ReturnType; + +async function runConfigureWithHarness(params: { + harness: ReturnType; + cfg?: Parameters[0]["cfg"]; + runtime?: RuntimeEnv; + options?: Parameters[0]["options"]; + accountOverrides?: Parameters[0]["accountOverrides"]; + shouldPromptAccountIds?: boolean; + forceAllowFrom?: boolean; +}) { + return await runSetupWizardConfigure({ + configure: whatsappConfigure, + cfg: params.cfg ?? {}, + runtime: params.runtime ?? createRuntime(), + prompter: params.harness.prompter, + options: params.options ?? {}, + accountOverrides: params.accountOverrides ?? {}, + shouldPromptAccountIds: params.shouldPromptAccountIds ?? false, + forceAllowFrom: params.forceAllowFrom ?? false, + }); +} + +function createSeparatePhoneHarness(params: { selectValues: string[]; textValues?: string[] }) { + return createQueuedWizardPrompter({ + confirmValues: [false], + selectValues: params.selectValues, + textValues: params.textValues, + }); +} + +async function runSeparatePhoneFlow(params: { selectValues: string[]; textValues?: string[] }) { + hoisted.pathExists.mockResolvedValue(true); + const harness = createSeparatePhoneHarness({ + selectValues: params.selectValues, + textValues: params.textValues, + }); + const result = await runConfigureWithHarness({ + harness, + }); + return { harness, result }; +} + describe("whatsappPlugin outbound sendMedia", () => { it("forwards mediaLocalRoots to sendMessageWhatsApp", async () => { const sendWhatsApp = vi.fn(async () => ({ @@ -149,6 +236,159 @@ describe("whatsapp directory", () => { }); }); +describe("whatsapp setup wizard", () => { + beforeAll(() => { + whatsappConfigure = createPluginSetupWizardConfigure(whatsappPlugin); + }); + + beforeEach(() => { + hoisted.loginWeb.mockReset(); + hoisted.pathExists.mockReset(); + hoisted.pathExists.mockResolvedValue(false); + hoisted.listWhatsAppAccountIds.mockReset(); + hoisted.listWhatsAppAccountIds.mockReturnValue([]); + hoisted.resolveDefaultWhatsAppAccountId.mockReset(); + hoisted.resolveDefaultWhatsAppAccountId.mockReturnValue(DEFAULT_ACCOUNT_ID); + hoisted.resolveWhatsAppAuthDir.mockReset(); + hoisted.resolveWhatsAppAuthDir.mockReturnValue({ authDir: "/tmp/openclaw-whatsapp-test" }); + }); + + it("applies owner allowlist when forceAllowFrom is enabled", async () => { + const harness = createQueuedWizardPrompter({ + confirmValues: [false], + textValues: ["+1 (555) 555-0123"], + }); + + const result = await runConfigureWithHarness({ + harness, + forceAllowFrom: true, + }); + + expect(result.accountId).toBe(DEFAULT_ACCOUNT_ID); + expect(hoisted.loginWeb).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 () => { + const { harness, result } = await runSeparatePhoneFlow({ + selectValues: ["separate", "disabled"], + }); + + 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 () => { + const { result } = await runSeparatePhoneFlow({ + selectValues: ["separate", "allowlist", "list"], + textValues: ["+1 (555) 555-0123, +15555550123, *"], + }); + + 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 () => { + hoisted.pathExists.mockResolvedValue(true); + const harness = createQueuedWizardPrompter({ + confirmValues: [false], + selectValues: ["personal"], + textValues: ["+1 (555) 111-2222"], + }); + + const result = await runConfigureWithHarness({ + harness, + }); + + 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 () => { + hoisted.pathExists.mockResolvedValue(true); + const harness = createSeparatePhoneHarness({ + selectValues: ["separate", "open"], + }); + + const result = await runConfigureWithHarness({ + harness, + cfg: { + channels: { + whatsapp: { + allowFrom: ["+15555550123"], + }, + }, + }, + }); + + 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 () => { + hoisted.pathExists.mockResolvedValue(false); + const harness = createQueuedWizardPrompter({ + confirmValues: [true], + selectValues: ["separate", "disabled"], + }); + const runtime = createRuntime(); + + await runConfigureWithHarness({ + harness, + runtime, + }); + + expect(hoisted.loginWeb).toHaveBeenCalledWith(false, undefined, runtime, DEFAULT_ACCOUNT_ID); + }); + + it("skips relink note when already linked and relink is declined", async () => { + hoisted.pathExists.mockResolvedValue(true); + const harness = createSeparatePhoneHarness({ + selectValues: ["separate", "disabled"], + }); + + await runConfigureWithHarness({ + harness, + }); + + expect(hoisted.loginWeb).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 () => { + hoisted.pathExists.mockResolvedValue(false); + const harness = createSeparatePhoneHarness({ + selectValues: ["separate", "disabled"], + }); + + await runConfigureWithHarness({ + harness, + }); + + expect(harness.note).toHaveBeenCalledWith( + expect.stringContaining("openclaw channels login"), + "WhatsApp", + ); + }); +}); + describe("whatsapp group policy", () => { it("uses generic channel group policy helpers", () => { const cfg = { diff --git a/extensions/whatsapp/src/normalize-target.test.ts b/extensions/whatsapp/src/normalize-target.test.ts deleted file mode 100644 index 8c57bbc197f..00000000000 --- a/extensions/whatsapp/src/normalize-target.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - isWhatsAppGroupJid, - isWhatsAppUserTarget, - normalizeWhatsAppTarget, -} from "./normalize-target.js"; - -describe("normalizeWhatsAppTarget", () => { - it("preserves group JIDs", () => { - expect(normalizeWhatsAppTarget("120363401234567890@g.us")).toBe("120363401234567890@g.us"); - expect(normalizeWhatsAppTarget("123456789-987654321@g.us")).toBe("123456789-987654321@g.us"); - expect(normalizeWhatsAppTarget("whatsapp:120363401234567890@g.us")).toBe( - "120363401234567890@g.us", - ); - }); - - it("normalizes direct JIDs to E.164", () => { - expect(normalizeWhatsAppTarget("1555123@s.whatsapp.net")).toBe("+1555123"); - }); - - it("normalizes user JIDs with device suffix to E.164", () => { - expect(normalizeWhatsAppTarget("41796666864:0@s.whatsapp.net")).toBe("+41796666864"); - expect(normalizeWhatsAppTarget("1234567890:123@s.whatsapp.net")).toBe("+1234567890"); - expect(normalizeWhatsAppTarget("41796666864@s.whatsapp.net")).toBe("+41796666864"); - }); - - it("normalizes LID JIDs to E.164", () => { - expect(normalizeWhatsAppTarget("123456789@lid")).toBe("+123456789"); - expect(normalizeWhatsAppTarget("123456789@LID")).toBe("+123456789"); - }); - - it("rejects invalid targets", () => { - expect(normalizeWhatsAppTarget("wat")).toBeNull(); - expect(normalizeWhatsAppTarget("whatsapp:")).toBeNull(); - expect(normalizeWhatsAppTarget("@g.us")).toBeNull(); - expect(normalizeWhatsAppTarget("whatsapp:group:@g.us")).toBeNull(); - expect(normalizeWhatsAppTarget("whatsapp:group:120363401234567890@g.us")).toBeNull(); - expect(normalizeWhatsAppTarget("group:123456789-987654321@g.us")).toBeNull(); - expect(normalizeWhatsAppTarget(" WhatsApp:Group:123456789-987654321@G.US ")).toBeNull(); - expect(normalizeWhatsAppTarget("abc@s.whatsapp.net")).toBeNull(); - }); - - it("handles repeated prefixes", () => { - expect(normalizeWhatsAppTarget("whatsapp:whatsapp:+1555")).toBe("+1555"); - expect(normalizeWhatsAppTarget("group:group:120@g.us")).toBeNull(); - }); -}); - -describe("isWhatsAppUserTarget", () => { - it("detects user JIDs with various formats", () => { - expect(isWhatsAppUserTarget("41796666864:0@s.whatsapp.net")).toBe(true); - expect(isWhatsAppUserTarget("1234567890@s.whatsapp.net")).toBe(true); - expect(isWhatsAppUserTarget("123456789@lid")).toBe(true); - expect(isWhatsAppUserTarget("123456789@LID")).toBe(true); - expect(isWhatsAppUserTarget("123@lid:0")).toBe(false); - expect(isWhatsAppUserTarget("abc@s.whatsapp.net")).toBe(false); - expect(isWhatsAppUserTarget("123456789-987654321@g.us")).toBe(false); - expect(isWhatsAppUserTarget("+1555123")).toBe(false); - }); -}); - -describe("isWhatsAppGroupJid", () => { - it("detects group JIDs with or without prefixes", () => { - expect(isWhatsAppGroupJid("120363401234567890@g.us")).toBe(true); - expect(isWhatsAppGroupJid("123456789-987654321@g.us")).toBe(true); - expect(isWhatsAppGroupJid("whatsapp:120363401234567890@g.us")).toBe(true); - expect(isWhatsAppGroupJid("whatsapp:group:120363401234567890@g.us")).toBe(false); - expect(isWhatsAppGroupJid("x@g.us")).toBe(false); - expect(isWhatsAppGroupJid("@g.us")).toBe(false); - expect(isWhatsAppGroupJid("120@g.usx")).toBe(false); - expect(isWhatsAppGroupJid("+1555123")).toBe(false); - }); -}); diff --git a/extensions/whatsapp/src/resolve-target.test.ts b/extensions/whatsapp/src/resolve-target.test.ts index 521c5995f41..9f315d6b82b 100644 --- a/extensions/whatsapp/src/resolve-target.test.ts +++ b/extensions/whatsapp/src/resolve-target.test.ts @@ -1,5 +1,10 @@ import { installCommonResolveTargetErrorCases } from "openclaw/plugin-sdk/testing"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + isWhatsAppGroupJid, + isWhatsAppUserTarget, + normalizeWhatsAppTarget, +} from "./normalize-target.js"; vi.mock("./runtime-api.js", async () => { const actual = await vi.importActual("./runtime-api.js"); @@ -161,3 +166,70 @@ describe("whatsapp resolveTarget", () => { }); }); }); + +describe("normalizeWhatsAppTarget", () => { + it("preserves group JIDs", () => { + expect(normalizeWhatsAppTarget("120363401234567890@g.us")).toBe("120363401234567890@g.us"); + expect(normalizeWhatsAppTarget("123456789-987654321@g.us")).toBe("123456789-987654321@g.us"); + expect(normalizeWhatsAppTarget("whatsapp:120363401234567890@g.us")).toBe( + "120363401234567890@g.us", + ); + }); + + it("normalizes direct JIDs to E.164", () => { + expect(normalizeWhatsAppTarget("1555123@s.whatsapp.net")).toBe("+1555123"); + }); + + it("normalizes user JIDs with device suffix to E.164", () => { + expect(normalizeWhatsAppTarget("41796666864:0@s.whatsapp.net")).toBe("+41796666864"); + expect(normalizeWhatsAppTarget("1234567890:123@s.whatsapp.net")).toBe("+1234567890"); + expect(normalizeWhatsAppTarget("41796666864@s.whatsapp.net")).toBe("+41796666864"); + }); + + it("normalizes LID JIDs to E.164", () => { + expect(normalizeWhatsAppTarget("123456789@lid")).toBe("+123456789"); + expect(normalizeWhatsAppTarget("123456789@LID")).toBe("+123456789"); + }); + + it("rejects invalid targets", () => { + expect(normalizeWhatsAppTarget("wat")).toBeNull(); + expect(normalizeWhatsAppTarget("whatsapp:")).toBeNull(); + expect(normalizeWhatsAppTarget("@g.us")).toBeNull(); + expect(normalizeWhatsAppTarget("whatsapp:group:@g.us")).toBeNull(); + expect(normalizeWhatsAppTarget("whatsapp:group:120363401234567890@g.us")).toBeNull(); + expect(normalizeWhatsAppTarget("group:123456789-987654321@g.us")).toBeNull(); + expect(normalizeWhatsAppTarget(" WhatsApp:Group:123456789-987654321@G.US ")).toBeNull(); + expect(normalizeWhatsAppTarget("abc@s.whatsapp.net")).toBeNull(); + }); + + it("handles repeated prefixes", () => { + expect(normalizeWhatsAppTarget("whatsapp:whatsapp:+1555")).toBe("+1555"); + expect(normalizeWhatsAppTarget("group:group:120@g.us")).toBeNull(); + }); +}); + +describe("isWhatsAppUserTarget", () => { + it("detects user JIDs with various formats", () => { + expect(isWhatsAppUserTarget("41796666864:0@s.whatsapp.net")).toBe(true); + expect(isWhatsAppUserTarget("1234567890@s.whatsapp.net")).toBe(true); + expect(isWhatsAppUserTarget("123456789@lid")).toBe(true); + expect(isWhatsAppUserTarget("123456789@LID")).toBe(true); + expect(isWhatsAppUserTarget("123@lid:0")).toBe(false); + expect(isWhatsAppUserTarget("abc@s.whatsapp.net")).toBe(false); + expect(isWhatsAppUserTarget("123456789-987654321@g.us")).toBe(false); + expect(isWhatsAppUserTarget("+1555123")).toBe(false); + }); +}); + +describe("isWhatsAppGroupJid", () => { + it("detects group JIDs with or without prefixes", () => { + expect(isWhatsAppGroupJid("120363401234567890@g.us")).toBe(true); + expect(isWhatsAppGroupJid("123456789-987654321@g.us")).toBe(true); + expect(isWhatsAppGroupJid("whatsapp:120363401234567890@g.us")).toBe(true); + expect(isWhatsAppGroupJid("whatsapp:group:120363401234567890@g.us")).toBe(false); + expect(isWhatsAppGroupJid("x@g.us")).toBe(false); + expect(isWhatsAppGroupJid("@g.us")).toBe(false); + expect(isWhatsAppGroupJid("120@g.usx")).toBe(false); + expect(isWhatsAppGroupJid("+1555123")).toBe(false); + }); +}); diff --git a/extensions/whatsapp/src/setup-surface.test.ts b/extensions/whatsapp/src/setup-surface.test.ts deleted file mode 100644 index 9597bd536de..00000000000 --- a/extensions/whatsapp/src/setup-surface.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; -import type { RuntimeEnv } from "../../../src/runtime.js"; -import { - createPluginSetupWizardConfigure, - createQueuedWizardPrompter, - runSetupWizardConfigure, -} from "../../../test/helpers/extensions/setup-wizard.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("./login.js", () => ({ - loginWeb: loginWebMock, -})); - -vi.mock("openclaw/plugin-sdk/setup", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/setup", - ); - return { - ...actual, - pathExists: pathExistsMock, - }; -}); - -vi.mock("./accounts.js", async () => { - const actual = await vi.importActual("./accounts.js"); - return { - ...actual, - listWhatsAppAccountIds: listWhatsAppAccountIdsMock, - resolveDefaultWhatsAppAccountId: resolveDefaultWhatsAppAccountIdMock, - resolveWhatsAppAuthDir: resolveWhatsAppAuthDirMock, - }; -}); - -function createRuntime(): RuntimeEnv { - return { - error: vi.fn(), - } as unknown as RuntimeEnv; -} - -let whatsappConfigure: ReturnType; - -async function runConfigureWithHarness(params: { - harness: ReturnType; - cfg?: Parameters[0]["cfg"]; - runtime?: RuntimeEnv; - options?: Parameters[0]["options"]; - accountOverrides?: Parameters[0]["accountOverrides"]; - shouldPromptAccountIds?: boolean; - forceAllowFrom?: boolean; -}) { - return await runSetupWizardConfigure({ - configure: whatsappConfigure, - cfg: params.cfg ?? {}, - runtime: params.runtime ?? createRuntime(), - prompter: params.harness.prompter, - options: params.options ?? {}, - accountOverrides: params.accountOverrides ?? {}, - shouldPromptAccountIds: params.shouldPromptAccountIds ?? false, - forceAllowFrom: params.forceAllowFrom ?? false, - }); -} - -function createSeparatePhoneHarness(params: { selectValues: string[]; textValues?: string[] }) { - return createQueuedWizardPrompter({ - confirmValues: [false], - selectValues: params.selectValues, - textValues: params.textValues, - }); -} - -async function runSeparatePhoneFlow(params: { selectValues: string[]; textValues?: string[] }) { - pathExistsMock.mockResolvedValue(true); - const harness = createSeparatePhoneHarness({ - selectValues: params.selectValues, - textValues: params.textValues, - }); - const result = await runConfigureWithHarness({ - harness, - }); - return { harness, result }; -} - -describe("whatsapp setup wizard", () => { - beforeAll(async () => { - vi.resetModules(); - const { whatsappPlugin } = await import("./channel.js"); - whatsappConfigure = createPluginSetupWizardConfigure(whatsappPlugin); - }); - - 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 = createQueuedWizardPrompter({ - confirmValues: [false], - textValues: ["+1 (555) 555-0123"], - }); - - const result = await runConfigureWithHarness({ - harness, - 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 () => { - const { harness, result } = await runSeparatePhoneFlow({ - selectValues: ["separate", "disabled"], - }); - - 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 () => { - const { result } = await runSeparatePhoneFlow({ - selectValues: ["separate", "allowlist", "list"], - textValues: ["+1 (555) 555-0123, +15555550123, *"], - }); - - 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 = createQueuedWizardPrompter({ - confirmValues: [false], - selectValues: ["personal"], - textValues: ["+1 (555) 111-2222"], - }); - - const result = await runConfigureWithHarness({ - harness, - }); - - 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 = createSeparatePhoneHarness({ - selectValues: ["separate", "open"], - }); - - const result = await runConfigureWithHarness({ - harness, - cfg: { - channels: { - whatsapp: { - allowFrom: ["+15555550123"], - }, - }, - }, - }); - - 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 = createQueuedWizardPrompter({ - confirmValues: [true], - selectValues: ["separate", "disabled"], - }); - const runtime = createRuntime(); - - await runConfigureWithHarness({ - harness, - runtime, - }); - - 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 = createSeparatePhoneHarness({ - selectValues: ["separate", "disabled"], - }); - - await runConfigureWithHarness({ - harness, - }); - - 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 = createSeparatePhoneHarness({ - selectValues: ["separate", "disabled"], - }); - - await runConfigureWithHarness({ - harness, - }); - - expect(harness.note).toHaveBeenCalledWith( - expect.stringContaining("openclaw channels login"), - "WhatsApp", - ); - }); -});