import { afterEach, describe, expect, it, vi } from "vitest"; import { createDirectoryTestRuntime, expectDirectorySurface, } from "../../../test/helpers/plugins/directory.ts"; import type { OpenClawConfig } from "../runtime-api.js"; const uploadGoogleChatAttachmentMock = vi.hoisted(() => vi.fn()); const sendGoogleChatMessageMock = vi.hoisted(() => vi.fn()); const resolveGoogleChatAccountMock = vi.hoisted(() => vi.fn()); const resolveGoogleChatOutboundSpaceMock = vi.hoisted(() => vi.fn()); const fetchRemoteMediaMock = vi.hoisted(() => vi.fn()); const loadOutboundMediaFromUrlMock = vi.hoisted(() => vi.fn()); const probeGoogleChatMock = vi.hoisted(() => vi.fn()); const startGoogleChatMonitorMock = vi.hoisted(() => vi.fn()); const DEFAULT_ACCOUNT_ID = "default"; function normalizeGoogleChatTarget(raw?: string | null): string | undefined { const trimmed = raw?.trim(); if (!trimmed) { return undefined; } const withoutPrefix = trimmed.replace(/^(googlechat|google-chat|gchat):/i, ""); const normalized = withoutPrefix .replace(/^user:(users\/)?/i, "users/") .replace(/^space:(spaces\/)?/i, "spaces/"); if (normalized.toLowerCase().startsWith("users/")) { const suffix = normalized.slice("users/".length); return suffix.includes("@") ? `users/${suffix.toLowerCase()}` : normalized; } if (normalized.toLowerCase().startsWith("spaces/")) { return normalized; } if (normalized.includes("@")) { return `users/${normalized.toLowerCase()}`; } return normalized; } function resolveGoogleChatAccountImpl(params: { cfg: OpenClawConfig; accountId?: string | null }) { const accountId = params.accountId?.trim() || DEFAULT_ACCOUNT_ID; const channelConfig = (params.cfg.channels?.googlechat ?? {}) as Record; const accounts = (channelConfig.accounts as Record> | undefined) ?? {}; const scoped = accountId === DEFAULT_ACCOUNT_ID ? {} : (accounts[accountId] ?? {}); const config = { ...channelConfig, ...scoped } as Record; const serviceAccount = config.serviceAccount; return { accountId, name: typeof config.name === "string" ? config.name : undefined, enabled: channelConfig.enabled !== false && scoped.enabled !== false, config, credentialSource: serviceAccount ? "inline" : "none", }; } vi.mock("./channel.runtime.js", () => { return { googleChatChannelRuntime: { probeGoogleChat: (...args: unknown[]) => probeGoogleChatMock(...args), resolveGoogleChatWebhookPath: () => "/googlechat/webhook", sendGoogleChatMessage: (...args: unknown[]) => sendGoogleChatMessageMock(...args), startGoogleChatMonitor: (...args: unknown[]) => startGoogleChatMonitorMock(...args), uploadGoogleChatAttachment: (...args: unknown[]) => uploadGoogleChatAttachmentMock(...args), }, }; }); vi.mock("./channel.deps.runtime.js", () => { return { DEFAULT_ACCOUNT_ID: "default", GoogleChatConfigSchema: {}, buildChannelConfigSchema: () => ({}), chunkTextForOutbound: (text: string, maxChars: number) => { const words = text.split(/\s+/).filter(Boolean); const chunks: string[] = []; let current = ""; for (const word of words) { const next = current ? `${current} ${word}` : word; if (current && next.length > maxChars) { chunks.push(current); current = word; continue; } current = next; } if (current) { chunks.push(current); } return chunks; }, createAccountStatusSink: () => () => {}, fetchRemoteMedia: (...args: unknown[]) => fetchRemoteMediaMock(...args), getChatChannelMeta: (id: string) => ({ id, name: id }), isGoogleChatSpaceTarget: (value: string) => value.toLowerCase().startsWith("spaces/"), isGoogleChatUserTarget: (value: string) => value.toLowerCase().startsWith("users/"), listGoogleChatAccountIds: (cfg: OpenClawConfig) => { const ids = Object.keys(cfg.channels?.googlechat?.accounts ?? {}); return ids.length > 0 ? ids : ["default"]; }, loadOutboundMediaFromUrl: (...args: unknown[]) => loadOutboundMediaFromUrlMock(...args), missingTargetError: (channel: string, hint: string) => new Error(`${channel} target is required (${hint})`), normalizeGoogleChatTarget, PAIRING_APPROVED_MESSAGE: "approved", resolveChannelMediaMaxBytes: (params: { cfg: OpenClawConfig; resolveChannelLimitMb: (args: { cfg: OpenClawConfig; accountId?: string; }) => number | undefined; accountId?: string; }) => { const limitMb = params.resolveChannelLimitMb({ cfg: params.cfg, accountId: params.accountId, }); return typeof limitMb === "number" ? limitMb * 1024 * 1024 : undefined; }, resolveDefaultGoogleChatAccountId: () => "default", resolveGoogleChatAccount: (...args: Parameters) => resolveGoogleChatAccountMock(...args), resolveGoogleChatOutboundSpace: (...args: unknown[]) => resolveGoogleChatOutboundSpaceMock(...args), runPassiveAccountLifecycle: async (params: { start: () => Promise }) => await params.start(), }; }); resolveGoogleChatAccountMock.mockImplementation(resolveGoogleChatAccountImpl); resolveGoogleChatOutboundSpaceMock.mockImplementation(async ({ target }: { target: string }) => { const normalized = normalizeGoogleChatTarget(target); if (!normalized) { throw new Error("Missing Google Chat target."); } return normalized.toLowerCase().startsWith("users/") ? `spaces/DM-${normalized.slice("users/".length)}` : normalized.replace(/\/messages\/.+$/, ""); }); loadOutboundMediaFromUrlMock.mockImplementation(async (mediaUrl: string) => ({ buffer: Buffer.from("default-bytes"), fileName: mediaUrl.split("/").pop() || "attachment", contentType: "application/octet-stream", })); fetchRemoteMediaMock.mockImplementation(async () => ({ buffer: Buffer.from("remote-bytes"), fileName: "remote.png", contentType: "image/png", })); import { googlechatPlugin } from "./channel.js"; afterEach(() => { vi.clearAllMocks(); resolveGoogleChatAccountMock.mockImplementation(resolveGoogleChatAccountImpl); resolveGoogleChatOutboundSpaceMock.mockImplementation(async ({ target }: { target: string }) => { const normalized = normalizeGoogleChatTarget(target); if (!normalized) { throw new Error("Missing Google Chat target."); } return normalized.toLowerCase().startsWith("users/") ? `spaces/DM-${normalized.slice("users/".length)}` : normalized.replace(/\/messages\/.+$/, ""); }); loadOutboundMediaFromUrlMock.mockImplementation(async (mediaUrl: string) => ({ buffer: Buffer.from("default-bytes"), fileName: mediaUrl.split("/").pop() || "attachment", contentType: "application/octet-stream", })); fetchRemoteMediaMock.mockImplementation(async () => ({ buffer: Buffer.from("remote-bytes"), fileName: "remote.png", contentType: "image/png", })); }); function createGoogleChatCfg(): OpenClawConfig { return { channels: { googlechat: { enabled: true, serviceAccount: { type: "service_account", client_email: "bot@example.com", private_key: "test-key", // pragma: allowlist secret token_uri: "https://oauth2.googleapis.com/token", }, }, }, }; } function setupRuntimeMediaMocks(params: { loadFileName: string; loadBytes: string }) { const loadOutboundMediaFromUrl = vi.fn(async () => ({ buffer: Buffer.from(params.loadBytes), fileName: params.loadFileName, contentType: "image/png", })); const fetchRemoteMedia = vi.fn(async () => ({ buffer: Buffer.from("remote-bytes"), fileName: "remote.png", contentType: "image/png", })); loadOutboundMediaFromUrlMock.mockImplementation(loadOutboundMediaFromUrl); fetchRemoteMediaMock.mockImplementation(fetchRemoteMedia); return { loadOutboundMediaFromUrl, fetchRemoteMedia }; } describe("googlechatPlugin outbound sendMedia", () => { it("chunks outbound text without requiring Google Chat runtime initialization", () => { const chunker = googlechatPlugin.outbound?.chunker; if (!chunker) { throw new Error("Expected googlechatPlugin.outbound.chunker to be defined"); } expect(chunker("alpha beta", 5)).toEqual(["alpha", "beta"]); }); it("loads local media with mediaLocalRoots via runtime media loader", async () => { const { loadOutboundMediaFromUrl, fetchRemoteMedia } = setupRuntimeMediaMocks({ loadFileName: "image.png", loadBytes: "image-bytes", }); uploadGoogleChatAttachmentMock.mockResolvedValue({ attachmentUploadToken: "token-1", }); sendGoogleChatMessageMock.mockResolvedValue({ messageName: "spaces/AAA/messages/msg-1", }); const cfg = createGoogleChatCfg(); const result = await googlechatPlugin.outbound?.sendMedia?.({ cfg, to: "spaces/AAA", text: "caption", mediaUrl: "/tmp/workspace/image.png", mediaLocalRoots: ["/tmp/workspace"], accountId: "default", }); expect(loadOutboundMediaFromUrl).toHaveBeenCalledWith( "/tmp/workspace/image.png", expect.objectContaining({ mediaLocalRoots: ["/tmp/workspace"], }), ); expect(fetchRemoteMedia).not.toHaveBeenCalled(); expect(uploadGoogleChatAttachmentMock).toHaveBeenCalledWith( expect.objectContaining({ space: "spaces/AAA", filename: "image.png", contentType: "image/png", }), ); expect(sendGoogleChatMessageMock).toHaveBeenCalledWith( expect.objectContaining({ space: "spaces/AAA", text: "caption", }), ); expect(result).toEqual({ channel: "googlechat", messageId: "spaces/AAA/messages/msg-1", chatId: "spaces/AAA", }); }); it("keeps remote URL media fetch on fetchRemoteMedia with maxBytes cap", async () => { const { loadOutboundMediaFromUrl, fetchRemoteMedia } = setupRuntimeMediaMocks({ loadFileName: "unused.png", loadBytes: "should-not-be-used", }); uploadGoogleChatAttachmentMock.mockResolvedValue({ attachmentUploadToken: "token-2", }); sendGoogleChatMessageMock.mockResolvedValue({ messageName: "spaces/AAA/messages/msg-2", }); const cfg = createGoogleChatCfg(); const result = await googlechatPlugin.outbound?.sendMedia?.({ cfg, to: "spaces/AAA", text: "caption", mediaUrl: "https://example.com/image.png", accountId: "default", }); expect(fetchRemoteMedia).toHaveBeenCalledWith( expect.objectContaining({ url: "https://example.com/image.png", maxBytes: 20 * 1024 * 1024, }), ); expect(loadOutboundMediaFromUrl).not.toHaveBeenCalled(); expect(uploadGoogleChatAttachmentMock).toHaveBeenCalledWith( expect.objectContaining({ space: "spaces/AAA", filename: "remote.png", contentType: "image/png", }), ); expect(sendGoogleChatMessageMock).toHaveBeenCalledWith( expect.objectContaining({ space: "spaces/AAA", text: "caption", }), ); expect(result).toEqual({ channel: "googlechat", messageId: "spaces/AAA/messages/msg-2", chatId: "spaces/AAA", }); }); }); describe("googlechatPlugin threading", () => { it("honors per-account replyToMode overrides", () => { const resolveReplyToMode = googlechatPlugin.threading?.resolveReplyToMode; if (!resolveReplyToMode) { throw new Error("Expected googlechatPlugin.threading.resolveReplyToMode to be defined"); } const cfg = { channels: { googlechat: { replyToMode: "all", accounts: { work: { replyToMode: "first", }, }, }, }, } as OpenClawConfig; expect(resolveReplyToMode({ cfg, accountId: "work" })).toBe("first"); expect(resolveReplyToMode({ cfg, accountId: "default" })).toBe("all"); }); }); const resolveTarget = googlechatPlugin.outbound?.resolveTarget; describe("googlechatPlugin outbound resolveTarget", () => { it("resolves valid chat targets", () => { if (!resolveTarget) { throw new Error("Expected googlechatPlugin.outbound.resolveTarget to be defined"); } const result = resolveTarget({ to: "spaces/AAA", mode: "explicit", allowFrom: [], }); expect(result.ok).toBe(true); if (!result.ok) { throw result.error; } expect(result.to).toBe("spaces/AAA"); }); it("resolves email targets", () => { if (!resolveTarget) { throw new Error("Expected googlechatPlugin.outbound.resolveTarget to be defined"); } const result = resolveTarget({ to: "user@example.com", mode: "explicit", allowFrom: [], }); expect(result.ok).toBe(true); if (!result.ok) { throw result.error; } expect(result.to).toBe("users/user@example.com"); }); it("errors on invalid targets", () => { if (!resolveTarget) { throw new Error("Expected googlechatPlugin.outbound.resolveTarget to be defined"); } const result = resolveTarget({ to: " ", mode: "explicit", allowFrom: [], }); expect(result.ok).toBe(false); if (result.ok) { throw new Error("Expected invalid target to fail"); } expect(result.error).toBeDefined(); }); it("errors when no target is provided", () => { if (!resolveTarget) { throw new Error("Expected googlechatPlugin.outbound.resolveTarget to be defined"); } const result = resolveTarget({ to: undefined, mode: "explicit", allowFrom: [], }); expect(result.ok).toBe(false); if (result.ok) { throw new Error("Expected missing target to fail"); } expect(result.error).toBeDefined(); }); }); describe("googlechatPlugin outbound cfg threading", () => { it("preserves accountId when sending pairing approvals", async () => { const cfg = { channels: { googlechat: { enabled: true, accounts: { work: { serviceAccount: { type: "service_account", }, }, }, }, }, }; const account = { accountId: "work", config: {}, credentialSource: "inline", }; resolveGoogleChatAccountMock.mockReturnValue(account); resolveGoogleChatOutboundSpaceMock.mockResolvedValue("spaces/WORK"); sendGoogleChatMessageMock.mockResolvedValue({ messageName: "spaces/WORK/messages/msg-1", }); await googlechatPlugin.pairing?.notifyApproval?.({ cfg: cfg as never, id: "user@example.com", accountId: "work", }); expect(resolveGoogleChatAccountMock).toHaveBeenCalledWith({ cfg, accountId: "work", }); expect(sendGoogleChatMessageMock).toHaveBeenCalledWith( expect.objectContaining({ account, space: "spaces/WORK", text: expect.any(String), }), ); }); it("threads resolved cfg into sendText account resolution", async () => { const cfg = { channels: { googlechat: { serviceAccount: { type: "service_account", }, }, }, }; const account = { accountId: "default", config: {}, credentialSource: "inline", }; resolveGoogleChatAccountMock.mockReturnValue(account); resolveGoogleChatOutboundSpaceMock.mockResolvedValue("spaces/AAA"); sendGoogleChatMessageMock.mockResolvedValue({ messageName: "spaces/AAA/messages/msg-1", }); await googlechatPlugin.outbound?.sendText?.({ cfg: cfg as never, to: "users/123", text: "hello", accountId: "default", }); expect(resolveGoogleChatAccountMock).toHaveBeenCalledWith({ cfg, accountId: "default", }); expect(sendGoogleChatMessageMock).toHaveBeenCalledWith( expect.objectContaining({ account, space: "spaces/AAA", text: "hello", }), ); }); it("threads resolved cfg into sendMedia account and media loading path", async () => { const cfg = { channels: { googlechat: { serviceAccount: { type: "service_account", }, mediaMaxMb: 8, }, }, }; const account = { accountId: "default", config: { mediaMaxMb: 20 }, credentialSource: "inline", }; const { fetchRemoteMedia } = setupRuntimeMediaMocks({ loadFileName: "unused.png", loadBytes: "should-not-be-used", }); resolveGoogleChatAccountMock.mockReturnValue(account); resolveGoogleChatOutboundSpaceMock.mockResolvedValue("spaces/AAA"); uploadGoogleChatAttachmentMock.mockResolvedValue({ attachmentUploadToken: "token-1", }); sendGoogleChatMessageMock.mockResolvedValue({ messageName: "spaces/AAA/messages/msg-2", }); await googlechatPlugin.outbound?.sendMedia?.({ cfg: cfg as never, to: "users/123", text: "photo", mediaUrl: "https://example.com/file.png", accountId: "default", }); expect(resolveGoogleChatAccountMock).toHaveBeenCalledWith({ cfg, accountId: "default", }); expect(fetchRemoteMedia).toHaveBeenCalledWith( expect.objectContaining({ url: "https://example.com/file.png", maxBytes: 8 * 1024 * 1024, }), ); expect(uploadGoogleChatAttachmentMock).toHaveBeenCalledWith( expect.objectContaining({ account, space: "spaces/AAA", filename: "remote.png", }), ); expect(sendGoogleChatMessageMock).toHaveBeenCalledWith( expect.objectContaining({ account, attachments: [{ attachmentUploadToken: "token-1", contentName: "remote.png" }], }), ); }); it("sends media without requiring Google Chat runtime initialization", async () => { const { loadOutboundMediaFromUrl } = setupRuntimeMediaMocks({ loadFileName: "image.png", loadBytes: "image-bytes", }); uploadGoogleChatAttachmentMock.mockResolvedValue({ attachmentUploadToken: "token-cold", }); sendGoogleChatMessageMock.mockResolvedValue({ messageName: "spaces/AAA/messages/msg-cold", }); const cfg = createGoogleChatCfg(); await expect( googlechatPlugin.outbound?.sendMedia?.({ cfg, to: "spaces/AAA", text: "caption", mediaUrl: "/tmp/workspace/image.png", mediaLocalRoots: ["/tmp/workspace"], accountId: "default", }), ).resolves.toEqual({ channel: "googlechat", messageId: "spaces/AAA/messages/msg-cold", chatId: "spaces/AAA", }); expect(loadOutboundMediaFromUrl).toHaveBeenCalledWith( "/tmp/workspace/image.png", expect.objectContaining({ mediaLocalRoots: ["/tmp/workspace"], }), ); }); }); describe("googlechat directory", () => { const runtimeEnv = createDirectoryTestRuntime() as never; it("lists peers and groups from config", async () => { const cfg = { channels: { googlechat: { serviceAccount: { client_email: "bot@example.com" }, dm: { allowFrom: ["users/alice", "googlechat:bob"] }, groups: { "spaces/AAA": {}, "spaces/BBB": {}, }, }, }, } as unknown as OpenClawConfig; const directory = expectDirectorySurface(googlechatPlugin.directory); await expect( directory.listPeers({ cfg, accountId: undefined, query: undefined, limit: undefined, runtime: runtimeEnv, }), ).resolves.toEqual( expect.arrayContaining([ { kind: "user", id: "users/alice" }, { kind: "user", id: "bob" }, ]), ); await expect( directory.listGroups({ cfg, accountId: undefined, query: undefined, limit: undefined, runtime: runtimeEnv, }), ).resolves.toEqual( expect.arrayContaining([ { kind: "group", id: "spaces/AAA" }, { kind: "group", id: "spaces/BBB" }, ]), ); }); it("normalizes spaced provider-prefixed dm allowlist entries", async () => { const cfg = { channels: { googlechat: { serviceAccount: { client_email: "bot@example.com" }, dm: { allowFrom: [" users/alice ", " googlechat:user:Bob@Example.com "] }, }, }, } as unknown as OpenClawConfig; const directory = expectDirectorySurface(googlechatPlugin.directory); await expect( directory.listPeers({ cfg, accountId: undefined, query: undefined, limit: undefined, runtime: runtimeEnv, }), ).resolves.toEqual( expect.arrayContaining([ { kind: "user", id: "users/alice" }, { kind: "user", id: "users/bob@example.com" }, ]), ); }); }); describe("googlechatPlugin security", () => { it("normalizes prefixed DM allowlist entries to lowercase user ids", () => { const security = googlechatPlugin.security; if (!security) { throw new Error("googlechat security unavailable"); } const resolveDmPolicy = security.resolveDmPolicy; const normalizeAllowEntry = googlechatPlugin.pairing?.normalizeAllowEntry; expect(resolveDmPolicy).toBeTypeOf("function"); expect(normalizeAllowEntry).toBeTypeOf("function"); const cfg = { channels: { googlechat: { serviceAccount: { client_email: "bot@example.com" }, dm: { policy: "allowlist", allowFrom: [" googlechat:user:Bob@Example.com "], }, }, }, } as OpenClawConfig; const account = googlechatPlugin.config.resolveAccount(cfg, "default"); const resolved = resolveDmPolicy!({ cfg, account }); if (!resolved) { throw new Error("googlechat resolveDmPolicy returned null"); } expect(resolved.policy).toBe("allowlist"); expect(resolved.allowFrom).toEqual([" googlechat:user:Bob@Example.com "]); expect(resolved.normalizeEntry?.(" googlechat:user:Bob@Example.com ")).toBe( "bob@example.com", ); expect(normalizeAllowEntry!(" users/Alice@Example.com ")).toBe("alice@example.com"); }); });