diff --git a/src/channels/plugins/base-types-assignability.test.ts b/src/channels/plugins/base-types-assignability.test.ts deleted file mode 100644 index 839146018fe..00000000000 --- a/src/channels/plugins/base-types-assignability.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, it, expectTypeOf } from "vitest"; -import type { DiscordProbe } from "../../discord/probe.js"; -import type { DiscordTokenResolution } from "../../discord/token.js"; -import type { IMessageProbe } from "../../imessage/probe.js"; -import type { LineProbeResult } from "../../line/types.js"; -import type { SignalProbe } from "../../signal/probe.js"; -import type { SlackProbe } from "../../slack/probe.js"; -import type { TelegramProbe } from "../../telegram/probe.js"; -import type { TelegramTokenResolution } from "../../telegram/token.js"; -import type { BaseProbeResult, BaseTokenResolution } from "./types.js"; - -describe("BaseProbeResult assignability", () => { - it("TelegramProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("DiscordProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("SlackProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("SignalProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("IMessageProbe satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("LineProbeResult satisfies BaseProbeResult", () => { - expectTypeOf().toMatchTypeOf(); - }); -}); - -describe("BaseTokenResolution assignability", () => { - it("TelegramTokenResolution satisfies BaseTokenResolution", () => { - expectTypeOf().toMatchTypeOf(); - }); - - it("DiscordTokenResolution satisfies BaseTokenResolution", () => { - expectTypeOf().toMatchTypeOf(); - }); -}); diff --git a/src/channels/plugins/catalog.test.ts b/src/channels/plugins/catalog.test.ts deleted file mode 100644 index d62fac8a8fc..00000000000 --- a/src/channels/plugins/catalog.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { describe, expect, it } from "vitest"; -import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js"; - -describe("channel plugin catalog", () => { - it("includes Microsoft Teams", () => { - const entry = getChannelPluginCatalogEntry("msteams"); - expect(entry?.install.npmSpec).toBe("@openclaw/msteams"); - expect(entry?.meta.aliases).toContain("teams"); - }); - - it("lists plugin catalog entries", () => { - const ids = listChannelPluginCatalogEntries().map((entry) => entry.id); - expect(ids).toContain("msteams"); - }); - - it("includes external catalog entries", () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-")); - const catalogPath = path.join(dir, "catalog.json"); - fs.writeFileSync( - catalogPath, - JSON.stringify({ - entries: [ - { - name: "@openclaw/demo-channel", - openclaw: { - channel: { - id: "demo-channel", - label: "Demo Channel", - selectionLabel: "Demo Channel", - docsPath: "/channels/demo-channel", - blurb: "Demo entry", - order: 999, - }, - install: { - npmSpec: "@openclaw/demo-channel", - }, - }, - }, - ], - }), - ); - - const ids = listChannelPluginCatalogEntries({ catalogPaths: [catalogPath] }).map( - (entry) => entry.id, - ); - expect(ids).toContain("demo-channel"); - }); -}); diff --git a/src/channels/plugins/config-writes.test.ts b/src/channels/plugins/config-writes.test.ts deleted file mode 100644 index 00fe9164f8e..00000000000 --- a/src/channels/plugins/config-writes.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveChannelConfigWrites } from "./config-writes.js"; - -describe("resolveChannelConfigWrites", () => { - it("defaults to allow when unset", () => { - const cfg = {}; - expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(true); - }); - - it("blocks when channel config disables writes", () => { - const cfg = { channels: { slack: { configWrites: false } } }; - expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(false); - }); - - it("account override wins over channel default", () => { - const cfg = { - channels: { - slack: { - configWrites: true, - accounts: { - work: { configWrites: false }, - }, - }, - }, - }; - expect(resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" })).toBe(false); - }); - - it("matches account ids case-insensitively", () => { - const cfg = { - channels: { - slack: { - configWrites: true, - accounts: { - Work: { configWrites: false }, - }, - }, - }, - }; - expect(resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" })).toBe(false); - }); -}); diff --git a/src/channels/plugins/directory-config.test.ts b/src/channels/plugins/directory-config.test.ts deleted file mode 100644 index ab043e1b36d..00000000000 --- a/src/channels/plugins/directory-config.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - listDiscordDirectoryGroupsFromConfig, - listDiscordDirectoryPeersFromConfig, - listSlackDirectoryGroupsFromConfig, - listSlackDirectoryPeersFromConfig, - listTelegramDirectoryGroupsFromConfig, - listTelegramDirectoryPeersFromConfig, - listWhatsAppDirectoryGroupsFromConfig, - listWhatsAppDirectoryPeersFromConfig, -} from "./directory-config.js"; - -describe("directory (config-backed)", () => { - it("lists Slack peers/groups from config", async () => { - const cfg = { - channels: { - slack: { - botToken: "xoxb-test", - appToken: "xapp-test", - dm: { allowFrom: ["U123", "user:U999"] }, - dms: { U234: {} }, - channels: { C111: { users: ["U777"] } }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const peers = await listSlackDirectoryPeersFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(peers?.map((e) => e.id).toSorted()).toEqual([ - "user:u123", - "user:u234", - "user:u777", - "user:u999", - ]); - - const groups = await listSlackDirectoryGroupsFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(groups?.map((e) => e.id)).toEqual(["channel:c111"]); - }); - - it("lists Discord peers/groups from config (numeric ids only)", async () => { - const cfg = { - channels: { - discord: { - token: "discord-test", - dm: { allowFrom: ["<@111>", "nope"] }, - dms: { "222": {} }, - guilds: { - "123": { - users: ["<@12345>", "not-an-id"], - channels: { - "555": {}, - "channel:666": {}, - general: {}, - }, - }, - }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const peers = await listDiscordDirectoryPeersFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(peers?.map((e) => e.id).toSorted()).toEqual(["user:111", "user:12345", "user:222"]); - - const groups = await listDiscordDirectoryGroupsFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(groups?.map((e) => e.id).toSorted()).toEqual(["channel:555", "channel:666"]); - }); - - it("lists Telegram peers/groups from config", async () => { - const cfg = { - channels: { - telegram: { - botToken: "telegram-test", - allowFrom: ["123", "alice", "tg:@bob"], - dms: { "456": {} }, - groups: { "-1001": {}, "*": {} }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const peers = await listTelegramDirectoryPeersFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(peers?.map((e) => e.id).toSorted()).toEqual(["123", "456", "@alice", "@bob"]); - - const groups = await listTelegramDirectoryGroupsFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(groups?.map((e) => e.id)).toEqual(["-1001"]); - }); - - it("lists WhatsApp peers/groups from config", async () => { - const cfg = { - channels: { - whatsapp: { - allowFrom: ["+15550000000", "*", "123@g.us"], - groups: { "999@g.us": { requireMention: true }, "*": {} }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const peers = await listWhatsAppDirectoryPeersFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(peers?.map((e) => e.id)).toEqual(["+15550000000"]); - - const groups = await listWhatsAppDirectoryGroupsFromConfig({ - cfg, - accountId: "default", - query: null, - limit: null, - }); - expect(groups?.map((e) => e.id)).toEqual(["999@g.us"]); - }); -}); diff --git a/src/channels/plugins/index.test.ts b/src/channels/plugins/index.test.ts deleted file mode 100644 index 63162f09018..00000000000 --- a/src/channels/plugins/index.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { ChannelPlugin } from "./types.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { createTestRegistry } from "../../test-utils/channel-plugins.js"; -import { listChannelPlugins } from "./index.js"; - -describe("channel plugin registry", () => { - const emptyRegistry = createTestRegistry([]); - - const createPlugin = (id: string): ChannelPlugin => ({ - id, - meta: { - id, - label: id, - selectionLabel: id, - docsPath: `/channels/${id}`, - blurb: "test", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({}), - }, - }); - - beforeEach(() => { - setActivePluginRegistry(emptyRegistry); - }); - - afterEach(() => { - setActivePluginRegistry(emptyRegistry); - }); - - it("sorts channel plugins by configured order", () => { - const registry = createTestRegistry( - ["slack", "telegram", "signal"].map((id) => ({ - pluginId: id, - plugin: createPlugin(id), - source: "test", - })), - ); - setActivePluginRegistry(registry); - const pluginIds = listChannelPlugins().map((plugin) => plugin.id); - expect(pluginIds).toEqual(["telegram", "slack", "signal"]); - }); -}); diff --git a/src/channels/plugins/load.test.ts b/src/channels/plugins/load.test.ts deleted file mode 100644 index f3daf0543c7..00000000000 --- a/src/channels/plugins/load.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import type { PluginRegistry } from "../../plugins/registry.js"; -import type { ChannelOutboundAdapter, ChannelPlugin } from "./types.js"; -import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { loadChannelPlugin } from "./load.js"; -import { loadChannelOutboundAdapter } from "./outbound/load.js"; - -const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({ - plugins: [], - tools: [], - channels, - providers: [], - gatewayHandlers: {}, - httpHandlers: [], - httpRoutes: [], - cliRegistrars: [], - services: [], - diagnostics: [], -}); - -const emptyRegistry = createRegistry([]); - -const msteamsOutbound: ChannelOutboundAdapter = { - deliveryMode: "direct", - sendText: async () => ({ channel: "msteams", messageId: "m1" }), - sendMedia: async () => ({ channel: "msteams", messageId: "m2" }), -}; - -const msteamsPlugin: ChannelPlugin = { - id: "msteams", - meta: { - id: "msteams", - label: "Microsoft Teams", - selectionLabel: "Microsoft Teams (Bot Framework)", - docsPath: "/channels/msteams", - blurb: "Bot Framework; enterprise support.", - aliases: ["teams"], - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({}), - }, - outbound: msteamsOutbound, -}; - -const registryWithMSTeams = createRegistry([ - { pluginId: "msteams", plugin: msteamsPlugin, source: "test" }, -]); - -describe("channel plugin loader", () => { - beforeEach(() => { - setActivePluginRegistry(emptyRegistry); - }); - - afterEach(() => { - setActivePluginRegistry(emptyRegistry); - }); - - it("loads channel plugins from the active registry", async () => { - setActivePluginRegistry(registryWithMSTeams); - const plugin = await loadChannelPlugin("msteams"); - expect(plugin).toBe(msteamsPlugin); - }); - - it("loads outbound adapters from registered plugins", async () => { - setActivePluginRegistry(registryWithMSTeams); - const outbound = await loadChannelOutboundAdapter("msteams"); - expect(outbound).toBe(msteamsOutbound); - }); -}); diff --git a/src/channels/plugins/normalize/imessage.test.ts b/src/channels/plugins/normalize/imessage.test.ts deleted file mode 100644 index a3cbf0501eb..00000000000 --- a/src/channels/plugins/normalize/imessage.test.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeIMessageMessagingTarget } from "./imessage.js"; - -describe("imessage target normalization", () => { - it("preserves service prefixes for handles", () => { - expect(normalizeIMessageMessagingTarget("sms:+1 (555) 222-3333")).toBe("sms:+15552223333"); - }); - - it("drops service prefixes for chat targets", () => { - expect(normalizeIMessageMessagingTarget("sms:chat_id:123")).toBe("chat_id:123"); - expect(normalizeIMessageMessagingTarget("imessage:CHAT_GUID:abc")).toBe("chat_guid:abc"); - expect(normalizeIMessageMessagingTarget("auto:ChatIdentifier:foo")).toBe("chat_identifier:foo"); - }); -}); diff --git a/src/channels/plugins/normalize/signal.test.ts b/src/channels/plugins/normalize/signal.test.ts deleted file mode 100644 index 547a8f30d91..00000000000 --- a/src/channels/plugins/normalize/signal.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./signal.js"; - -describe("signal target normalization", () => { - it("normalizes uuid targets by stripping uuid:", () => { - expect(normalizeSignalMessagingTarget("uuid:123E4567-E89B-12D3-A456-426614174000")).toBe( - "123e4567-e89b-12d3-a456-426614174000", - ); - }); - - it("normalizes signal:uuid targets", () => { - expect(normalizeSignalMessagingTarget("signal:uuid:123E4567-E89B-12D3-A456-426614174000")).toBe( - "123e4567-e89b-12d3-a456-426614174000", - ); - }); - - it("preserves case for group targets", () => { - expect( - normalizeSignalMessagingTarget("signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="), - ).toBe("group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="); - }); - - it("accepts uuid prefixes for target detection", () => { - expect(looksLikeSignalTargetId("uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true); - expect(looksLikeSignalTargetId("signal:uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true); - }); - - it("accepts compact UUIDs for target detection", () => { - expect(looksLikeSignalTargetId("123e4567e89b12d3a456426614174000")).toBe(true); - expect(looksLikeSignalTargetId("uuid:123e4567e89b12d3a456426614174000")).toBe(true); - }); - - it("rejects invalid uuid prefixes", () => { - expect(looksLikeSignalTargetId("uuid:")).toBe(false); - expect(looksLikeSignalTargetId("uuid:not-a-uuid")).toBe(false); - }); -}); diff --git a/src/channels/plugins/onboarding/signal.test.ts b/src/channels/plugins/onboarding/signal.test.ts deleted file mode 100644 index 23f218bd4c4..00000000000 --- a/src/channels/plugins/onboarding/signal.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { normalizeSignalAccountInput } from "./signal.js"; - -describe("normalizeSignalAccountInput", () => { - it("accepts already normalized numbers", () => { - expect(normalizeSignalAccountInput("+15555550123")).toBe("+15555550123"); - }); - - it("normalizes formatted input", () => { - expect(normalizeSignalAccountInput(" +1 (555) 000-1234 ")).toBe("+15550001234"); - }); - - it("rejects empty input", () => { - expect(normalizeSignalAccountInput(" ")).toBeNull(); - }); - - it("rejects non-numeric input", () => { - expect(normalizeSignalAccountInput("ok")).toBeNull(); - expect(normalizeSignalAccountInput("++--")).toBeNull(); - }); - - it("rejects inputs with stray + characters", () => { - expect(normalizeSignalAccountInput("++12345")).toBeNull(); - expect(normalizeSignalAccountInput("+1+2345")).toBeNull(); - }); - - it("rejects numbers that are too short or too long", () => { - expect(normalizeSignalAccountInput("+1234")).toBeNull(); - expect(normalizeSignalAccountInput("+1234567890123456")).toBeNull(); - }); -}); diff --git a/src/channels/plugins/outbound/telegram.test.ts b/src/channels/plugins/outbound/telegram.test.ts deleted file mode 100644 index 7981addf566..00000000000 --- a/src/channels/plugins/outbound/telegram.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../config/config.js"; -import { telegramOutbound } from "./telegram.js"; - -describe("telegramOutbound.sendPayload", () => { - it("sends text payload with buttons", async () => { - const sendTelegram = vi.fn(async () => ({ messageId: "m1", chatId: "c1" })); - - const result = await telegramOutbound.sendPayload?.({ - cfg: {} as OpenClawConfig, - to: "telegram:123", - text: "ignored", - payload: { - text: "Hello", - channelData: { - telegram: { - buttons: [[{ text: "Option", callback_data: "/option" }]], - }, - }, - }, - deps: { sendTelegram }, - }); - - expect(sendTelegram).toHaveBeenCalledTimes(1); - expect(sendTelegram).toHaveBeenCalledWith( - "telegram:123", - "Hello", - expect.objectContaining({ - buttons: [[{ text: "Option", callback_data: "/option" }]], - textMode: "html", - }), - ); - expect(result).toEqual({ channel: "telegram", messageId: "m1", chatId: "c1" }); - }); - - it("sends media payloads and attaches buttons only to first", async () => { - const sendTelegram = vi - .fn() - .mockResolvedValueOnce({ messageId: "m1", chatId: "c1" }) - .mockResolvedValueOnce({ messageId: "m2", chatId: "c1" }); - - const result = await telegramOutbound.sendPayload?.({ - cfg: {} as OpenClawConfig, - to: "telegram:123", - text: "ignored", - payload: { - text: "Caption", - mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], - channelData: { - telegram: { - buttons: [[{ text: "Go", callback_data: "/go" }]], - }, - }, - }, - deps: { sendTelegram }, - }); - - expect(sendTelegram).toHaveBeenCalledTimes(2); - expect(sendTelegram).toHaveBeenNthCalledWith( - 1, - "telegram:123", - "Caption", - expect.objectContaining({ - mediaUrl: "https://example.com/a.png", - buttons: [[{ text: "Go", callback_data: "/go" }]], - }), - ); - const secondOpts = sendTelegram.mock.calls[1]?.[2] as { buttons?: unknown } | undefined; - expect(sendTelegram).toHaveBeenNthCalledWith( - 2, - "telegram:123", - "", - expect.objectContaining({ - mediaUrl: "https://example.com/b.png", - }), - ); - expect(secondOpts?.buttons).toBeUndefined(); - expect(result).toEqual({ channel: "telegram", messageId: "m2", chatId: "c1" }); - }); -}); diff --git a/src/channels/plugins/outbound/whatsapp.test.ts b/src/channels/plugins/outbound/whatsapp.test.ts deleted file mode 100644 index 7922ed00795..00000000000 --- a/src/channels/plugins/outbound/whatsapp.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { whatsappOutbound } from "./whatsapp.js"; - -describe("whatsappOutbound.resolveTarget", () => { - it("returns error when no target is provided even with allowFrom", () => { - const result = whatsappOutbound.resolveTarget?.({ - to: undefined, - allowFrom: ["+15551234567"], - mode: "implicit", - }); - - expect(result).toEqual({ - ok: false, - error: expect.any(Error), - }); - }); - - it("returns error when implicit target is not in allowFrom", () => { - const result = whatsappOutbound.resolveTarget?.({ - to: "+15550000000", - allowFrom: ["+15551234567"], - mode: "implicit", - }); - - expect(result).toEqual({ - ok: false, - error: expect.any(Error), - }); - }); - - it("keeps group JID targets even when allowFrom does not contain them", () => { - const result = whatsappOutbound.resolveTarget?.({ - to: "120363401234567890@g.us", - allowFrom: ["+15551234567"], - mode: "implicit", - }); - - expect(result).toEqual({ - ok: true, - to: "120363401234567890@g.us", - }); - }); -}); diff --git a/src/channels/plugins/plugins-channel.test.ts b/src/channels/plugins/plugins-channel.test.ts new file mode 100644 index 00000000000..91277158d2e --- /dev/null +++ b/src/channels/plugins/plugins-channel.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { normalizeIMessageMessagingTarget } from "./normalize/imessage.js"; +import { looksLikeSignalTargetId, normalizeSignalMessagingTarget } from "./normalize/signal.js"; +import { normalizeSignalAccountInput } from "./onboarding/signal.js"; +import { telegramOutbound } from "./outbound/telegram.js"; +import { whatsappOutbound } from "./outbound/whatsapp.js"; + +describe("imessage target normalization", () => { + it("preserves service prefixes for handles", () => { + expect(normalizeIMessageMessagingTarget("sms:+1 (555) 222-3333")).toBe("sms:+15552223333"); + }); + + it("drops service prefixes for chat targets", () => { + expect(normalizeIMessageMessagingTarget("sms:chat_id:123")).toBe("chat_id:123"); + expect(normalizeIMessageMessagingTarget("imessage:CHAT_GUID:abc")).toBe("chat_guid:abc"); + expect(normalizeIMessageMessagingTarget("auto:ChatIdentifier:foo")).toBe("chat_identifier:foo"); + }); +}); + +describe("signal target normalization", () => { + it("normalizes uuid targets by stripping uuid:", () => { + expect(normalizeSignalMessagingTarget("uuid:123E4567-E89B-12D3-A456-426614174000")).toBe( + "123e4567-e89b-12d3-a456-426614174000", + ); + }); + + it("normalizes signal:uuid targets", () => { + expect(normalizeSignalMessagingTarget("signal:uuid:123E4567-E89B-12D3-A456-426614174000")).toBe( + "123e4567-e89b-12d3-a456-426614174000", + ); + }); + + it("preserves case for group targets", () => { + expect( + normalizeSignalMessagingTarget("signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="), + ).toBe("group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="); + }); + + it("accepts uuid prefixes for target detection", () => { + expect(looksLikeSignalTargetId("uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true); + expect(looksLikeSignalTargetId("signal:uuid:123e4567-e89b-12d3-a456-426614174000")).toBe(true); + }); + + it("accepts compact UUIDs for target detection", () => { + expect(looksLikeSignalTargetId("123e4567e89b12d3a456426614174000")).toBe(true); + expect(looksLikeSignalTargetId("uuid:123e4567e89b12d3a456426614174000")).toBe(true); + }); + + it("rejects invalid uuid prefixes", () => { + expect(looksLikeSignalTargetId("uuid:")).toBe(false); + expect(looksLikeSignalTargetId("uuid:not-a-uuid")).toBe(false); + }); +}); + +describe("telegramOutbound.sendPayload", () => { + it("sends text payload with buttons", async () => { + const sendTelegram = vi.fn(async () => ({ messageId: "m1", chatId: "c1" })); + + const result = await telegramOutbound.sendPayload?.({ + cfg: {} as OpenClawConfig, + to: "telegram:123", + text: "ignored", + payload: { + text: "Hello", + channelData: { + telegram: { + buttons: [[{ text: "Option", callback_data: "/option" }]], + }, + }, + }, + deps: { sendTelegram }, + }); + + expect(sendTelegram).toHaveBeenCalledTimes(1); + expect(sendTelegram).toHaveBeenCalledWith( + "telegram:123", + "Hello", + expect.objectContaining({ + buttons: [[{ text: "Option", callback_data: "/option" }]], + textMode: "html", + }), + ); + expect(result).toEqual({ channel: "telegram", messageId: "m1", chatId: "c1" }); + }); + + it("sends media payloads and attaches buttons only to first", async () => { + const sendTelegram = vi + .fn() + .mockResolvedValueOnce({ messageId: "m1", chatId: "c1" }) + .mockResolvedValueOnce({ messageId: "m2", chatId: "c1" }); + + const result = await telegramOutbound.sendPayload?.({ + cfg: {} as OpenClawConfig, + to: "telegram:123", + text: "ignored", + payload: { + text: "Caption", + mediaUrls: ["https://example.com/a.png", "https://example.com/b.png"], + channelData: { + telegram: { + buttons: [[{ text: "Go", callback_data: "/go" }]], + }, + }, + }, + deps: { sendTelegram }, + }); + + expect(sendTelegram).toHaveBeenCalledTimes(2); + expect(sendTelegram).toHaveBeenNthCalledWith( + 1, + "telegram:123", + "Caption", + expect.objectContaining({ + mediaUrl: "https://example.com/a.png", + buttons: [[{ text: "Go", callback_data: "/go" }]], + }), + ); + const secondOpts = sendTelegram.mock.calls[1]?.[2] as { buttons?: unknown } | undefined; + expect(sendTelegram).toHaveBeenNthCalledWith( + 2, + "telegram:123", + "", + expect.objectContaining({ + mediaUrl: "https://example.com/b.png", + }), + ); + expect(secondOpts?.buttons).toBeUndefined(); + expect(result).toEqual({ channel: "telegram", messageId: "m2", chatId: "c1" }); + }); +}); + +describe("whatsappOutbound.resolveTarget", () => { + it("returns error when no target is provided even with allowFrom", () => { + const result = whatsappOutbound.resolveTarget?.({ + to: undefined, + allowFrom: ["+15551234567"], + mode: "implicit", + }); + + expect(result).toEqual({ + ok: false, + error: expect.any(Error), + }); + }); + + it("returns error when implicit target is not in allowFrom", () => { + const result = whatsappOutbound.resolveTarget?.({ + to: "+15550000000", + allowFrom: ["+15551234567"], + mode: "implicit", + }); + + expect(result).toEqual({ + ok: false, + error: expect.any(Error), + }); + }); + + it("keeps group JID targets even when allowFrom does not contain them", () => { + const result = whatsappOutbound.resolveTarget?.({ + to: "120363401234567890@g.us", + allowFrom: ["+15551234567"], + mode: "implicit", + }); + + expect(result).toEqual({ + ok: true, + to: "120363401234567890@g.us", + }); + }); +}); + +describe("normalizeSignalAccountInput", () => { + it("accepts already normalized numbers", () => { + expect(normalizeSignalAccountInput("+15555550123")).toBe("+15555550123"); + }); + + it("normalizes formatted input", () => { + expect(normalizeSignalAccountInput(" +1 (555) 000-1234 ")).toBe("+15550001234"); + }); + + it("rejects empty input", () => { + expect(normalizeSignalAccountInput(" ")).toBeNull(); + }); + + it("rejects non-numeric input", () => { + expect(normalizeSignalAccountInput("ok")).toBeNull(); + expect(normalizeSignalAccountInput("++--")).toBeNull(); + }); + + it("rejects inputs with stray + characters", () => { + expect(normalizeSignalAccountInput("++12345")).toBeNull(); + expect(normalizeSignalAccountInput("+1+2345")).toBeNull(); + }); + + it("rejects numbers that are too short or too long", () => { + expect(normalizeSignalAccountInput("+1234")).toBeNull(); + expect(normalizeSignalAccountInput("+1234567890123456")).toBeNull(); + }); +}); diff --git a/src/channels/plugins/plugins-core.test.ts b/src/channels/plugins/plugins-core.test.ts new file mode 100644 index 00000000000..64daeb574a2 --- /dev/null +++ b/src/channels/plugins/plugins-core.test.ts @@ -0,0 +1,395 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, expectTypeOf, it } from "vitest"; +import type { DiscordProbe } from "../../discord/probe.js"; +import type { DiscordTokenResolution } from "../../discord/token.js"; +import type { IMessageProbe } from "../../imessage/probe.js"; +import type { LineProbeResult } from "../../line/types.js"; +import type { PluginRegistry } from "../../plugins/registry.js"; +import type { SignalProbe } from "../../signal/probe.js"; +import type { SlackProbe } from "../../slack/probe.js"; +import type { TelegramProbe } from "../../telegram/probe.js"; +import type { TelegramTokenResolution } from "../../telegram/token.js"; +import type { ChannelOutboundAdapter, ChannelPlugin } from "./types.js"; +import type { BaseProbeResult, BaseTokenResolution } from "./types.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { getChannelPluginCatalogEntry, listChannelPluginCatalogEntries } from "./catalog.js"; +import { resolveChannelConfigWrites } from "./config-writes.js"; +import { + listDiscordDirectoryGroupsFromConfig, + listDiscordDirectoryPeersFromConfig, + listSlackDirectoryGroupsFromConfig, + listSlackDirectoryPeersFromConfig, + listTelegramDirectoryGroupsFromConfig, + listTelegramDirectoryPeersFromConfig, + listWhatsAppDirectoryGroupsFromConfig, + listWhatsAppDirectoryPeersFromConfig, +} from "./directory-config.js"; +import { listChannelPlugins } from "./index.js"; +import { loadChannelPlugin } from "./load.js"; +import { loadChannelOutboundAdapter } from "./outbound/load.js"; + +describe("channel plugin registry", () => { + const emptyRegistry = createTestRegistry([]); + + const createPlugin = (id: string): ChannelPlugin => ({ + id, + meta: { + id, + label: id, + selectionLabel: id, + docsPath: `/channels/${id}`, + blurb: "test", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + }); + + beforeEach(() => { + setActivePluginRegistry(emptyRegistry); + }); + + afterEach(() => { + setActivePluginRegistry(emptyRegistry); + }); + + it("sorts channel plugins by configured order", () => { + const registry = createTestRegistry( + ["slack", "telegram", "signal"].map((id) => ({ + pluginId: id, + plugin: createPlugin(id), + source: "test", + })), + ); + setActivePluginRegistry(registry); + const pluginIds = listChannelPlugins().map((plugin) => plugin.id); + expect(pluginIds).toEqual(["telegram", "slack", "signal"]); + }); +}); + +describe("channel plugin catalog", () => { + it("includes Microsoft Teams", () => { + const entry = getChannelPluginCatalogEntry("msteams"); + expect(entry?.install.npmSpec).toBe("@openclaw/msteams"); + expect(entry?.meta.aliases).toContain("teams"); + }); + + it("lists plugin catalog entries", () => { + const ids = listChannelPluginCatalogEntries().map((entry) => entry.id); + expect(ids).toContain("msteams"); + }); + + it("includes external catalog entries", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-")); + const catalogPath = path.join(dir, "catalog.json"); + fs.writeFileSync( + catalogPath, + JSON.stringify({ + entries: [ + { + name: "@openclaw/demo-channel", + openclaw: { + channel: { + id: "demo-channel", + label: "Demo Channel", + selectionLabel: "Demo Channel", + docsPath: "/channels/demo-channel", + blurb: "Demo entry", + order: 999, + }, + install: { + npmSpec: "@openclaw/demo-channel", + }, + }, + }, + ], + }), + ); + + const ids = listChannelPluginCatalogEntries({ catalogPaths: [catalogPath] }).map( + (entry) => entry.id, + ); + expect(ids).toContain("demo-channel"); + }); +}); + +const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry => ({ + plugins: [], + tools: [], + channels, + providers: [], + gatewayHandlers: {}, + httpHandlers: [], + httpRoutes: [], + cliRegistrars: [], + services: [], + diagnostics: [], +}); + +const emptyRegistry = createRegistry([]); + +const msteamsOutbound: ChannelOutboundAdapter = { + deliveryMode: "direct", + sendText: async () => ({ channel: "msteams", messageId: "m1" }), + sendMedia: async () => ({ channel: "msteams", messageId: "m2" }), +}; + +const msteamsPlugin: ChannelPlugin = { + id: "msteams", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams (Bot Framework)", + docsPath: "/channels/msteams", + blurb: "Bot Framework; enterprise support.", + aliases: ["teams"], + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + outbound: msteamsOutbound, +}; + +const registryWithMSTeams = createRegistry([ + { pluginId: "msteams", plugin: msteamsPlugin, source: "test" }, +]); + +describe("channel plugin loader", () => { + beforeEach(() => { + setActivePluginRegistry(emptyRegistry); + }); + + afterEach(() => { + setActivePluginRegistry(emptyRegistry); + }); + + it("loads channel plugins from the active registry", async () => { + setActivePluginRegistry(registryWithMSTeams); + const plugin = await loadChannelPlugin("msteams"); + expect(plugin).toBe(msteamsPlugin); + }); + + it("loads outbound adapters from registered plugins", async () => { + setActivePluginRegistry(registryWithMSTeams); + const outbound = await loadChannelOutboundAdapter("msteams"); + expect(outbound).toBe(msteamsOutbound); + }); +}); + +describe("BaseProbeResult assignability", () => { + it("TelegramProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("DiscordProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("SlackProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("SignalProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("IMessageProbe satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("LineProbeResult satisfies BaseProbeResult", () => { + expectTypeOf().toMatchTypeOf(); + }); +}); + +describe("BaseTokenResolution assignability", () => { + it("TelegramTokenResolution satisfies BaseTokenResolution", () => { + expectTypeOf().toMatchTypeOf(); + }); + + it("DiscordTokenResolution satisfies BaseTokenResolution", () => { + expectTypeOf().toMatchTypeOf(); + }); +}); + +describe("resolveChannelConfigWrites", () => { + it("defaults to allow when unset", () => { + const cfg = {}; + expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(true); + }); + + it("blocks when channel config disables writes", () => { + const cfg = { channels: { slack: { configWrites: false } } }; + expect(resolveChannelConfigWrites({ cfg, channelId: "slack" })).toBe(false); + }); + + it("account override wins over channel default", () => { + const cfg = { + channels: { + slack: { + configWrites: true, + accounts: { + work: { configWrites: false }, + }, + }, + }, + }; + expect(resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" })).toBe(false); + }); + + it("matches account ids case-insensitively", () => { + const cfg = { + channels: { + slack: { + configWrites: true, + accounts: { + Work: { configWrites: false }, + }, + }, + }, + }; + expect(resolveChannelConfigWrites({ cfg, channelId: "slack", accountId: "work" })).toBe(false); + }); +}); + +describe("directory (config-backed)", () => { + it("lists Slack peers/groups from config", async () => { + const cfg = { + channels: { + slack: { + botToken: "xoxb-test", + appToken: "xapp-test", + dm: { allowFrom: ["U123", "user:U999"] }, + dms: { U234: {} }, + channels: { C111: { users: ["U777"] } }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + const peers = await listSlackDirectoryPeersFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(peers?.map((e) => e.id).toSorted()).toEqual([ + "user:u123", + "user:u234", + "user:u777", + "user:u999", + ]); + + const groups = await listSlackDirectoryGroupsFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(groups?.map((e) => e.id)).toEqual(["channel:c111"]); + }); + + it("lists Discord peers/groups from config (numeric ids only)", async () => { + const cfg = { + channels: { + discord: { + token: "discord-test", + dm: { allowFrom: ["<@111>", "nope"] }, + dms: { "222": {} }, + guilds: { + "123": { + users: ["<@12345>", "not-an-id"], + channels: { + "555": {}, + "channel:666": {}, + general: {}, + }, + }, + }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + const peers = await listDiscordDirectoryPeersFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(peers?.map((e) => e.id).toSorted()).toEqual(["user:111", "user:12345", "user:222"]); + + const groups = await listDiscordDirectoryGroupsFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(groups?.map((e) => e.id).toSorted()).toEqual(["channel:555", "channel:666"]); + }); + + it("lists Telegram peers/groups from config", async () => { + const cfg = { + channels: { + telegram: { + botToken: "telegram-test", + allowFrom: ["123", "alice", "tg:@bob"], + dms: { "456": {} }, + groups: { "-1001": {}, "*": {} }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + const peers = await listTelegramDirectoryPeersFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(peers?.map((e) => e.id).toSorted()).toEqual(["123", "456", "@alice", "@bob"]); + + const groups = await listTelegramDirectoryGroupsFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(groups?.map((e) => e.id)).toEqual(["-1001"]); + }); + + it("lists WhatsApp peers/groups from config", async () => { + const cfg = { + channels: { + whatsapp: { + allowFrom: ["+15550000000", "*", "123@g.us"], + groups: { "999@g.us": { requireMention: true }, "*": {} }, + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + const peers = await listWhatsAppDirectoryPeersFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(peers?.map((e) => e.id)).toEqual(["+15550000000"]); + + const groups = await listWhatsAppDirectoryGroupsFromConfig({ + cfg, + accountId: "default", + query: null, + limit: null, + }); + expect(groups?.map((e) => e.id)).toEqual(["999@g.us"]); + }); +});