diff --git a/extensions/zalo/src/api.ts b/extensions/zalo/src/api.ts index 9bef1ce680e..9a9c3544768 100644 --- a/extensions/zalo/src/api.ts +++ b/extensions/zalo/src/api.ts @@ -25,7 +25,9 @@ export type ZaloMessage = { from: { id: string; name?: string; + display_name?: string; avatar?: string; + is_bot?: boolean; }; chat: { id: string; @@ -33,9 +35,10 @@ export type ZaloMessage = { }; date: number; text?: string; - photo?: string; + photo_url?: string; caption?: string; sticker?: string; + message_type?: string; }; export type ZaloUpdate = { diff --git a/extensions/zalo/src/monitor.image.polling.test.ts b/extensions/zalo/src/monitor.image.polling.test.ts new file mode 100644 index 00000000000..1ba44cbe306 --- /dev/null +++ b/extensions/zalo/src/monitor.image.polling.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; +import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js"; +import type { ResolvedZaloAccount } from "./accounts.js"; + +const getWebhookInfoMock = vi.hoisted(() => vi.fn(async () => ({ ok: true, result: { url: "" } }))); +const deleteWebhookMock = vi.hoisted(() => vi.fn(async () => ({ ok: true, result: { url: "" } }))); +const setWebhookMock = vi.hoisted(() => vi.fn(async () => ({ ok: true, result: { url: "" } }))); +const getUpdatesMock = vi.hoisted(() => vi.fn(() => new Promise(() => {}))); +const getZaloRuntimeMock = vi.hoisted(() => vi.fn()); + +vi.mock("./api.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + deleteWebhook: deleteWebhookMock, + getUpdates: getUpdatesMock, + getWebhookInfo: getWebhookInfoMock, + setWebhook: setWebhookMock, + }; +}); + +vi.mock("./runtime.js", () => ({ + getZaloRuntime: getZaloRuntimeMock, +})); + +const TEST_ACCOUNT: ResolvedZaloAccount = { + accountId: "default", + enabled: true, + token: "zalo-token", // pragma: allowlist secret + tokenSource: "config", + config: { + dmPolicy: "open", + }, +}; + +const TEST_CONFIG = { + channels: { + zalo: { + enabled: true, + accounts: { + default: { + enabled: true, + dmPolicy: "open", + }, + }, + }, + }, +} as OpenClawConfig; + +function createRuntimeEnv() { + return { + log: vi.fn<(message: string) => void>(), + error: vi.fn<(message: string) => void>(), + }; +} + +describe("Zalo polling image handling", () => { + const finalizeInboundContextMock = vi.fn((ctx: Record) => ctx); + const recordInboundSessionMock = vi.fn(async () => undefined); + const fetchRemoteMediaMock = vi.fn(async () => ({ + buffer: Buffer.from("image-bytes"), + contentType: "image/jpeg", + })); + const saveMediaBufferMock = vi.fn(async () => ({ + path: "/tmp/zalo-photo.jpg", + contentType: "image/jpeg", + })); + + beforeEach(() => { + vi.clearAllMocks(); + + getZaloRuntimeMock.mockReturnValue( + createPluginRuntimeMock({ + channel: { + media: { + fetchRemoteMedia: + fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"], + saveMediaBuffer: + saveMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"], + }, + reply: { + finalizeInboundContext: + finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], + dispatchReplyWithBufferedBlockDispatcher: vi.fn( + async () => undefined, + ) as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"], + }, + session: { + recordInboundSession: + recordInboundSessionMock as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"], + }, + commands: { + shouldComputeCommandAuthorized: vi.fn( + () => false, + ) as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"], + resolveCommandAuthorizedFromAuthorizers: vi.fn( + () => false, + ) as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"], + isControlCommandMessage: vi.fn( + () => false, + ) as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"], + }, + }, + }), + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("downloads inbound image media from photo_url and preserves display_name", async () => { + getUpdatesMock + .mockResolvedValueOnce({ + ok: true, + result: { + event_name: "message.image.received", + message: { + chat: { + id: "chat-123", + chat_type: "PRIVATE" as const, + }, + message_id: "msg-123", + date: 1774084566880, + message_type: "CHAT_PHOTO", + from: { + id: "user-123", + is_bot: false, + display_name: "Test User", + }, + photo_url: "https://example.com/test-image.jpg", + caption: "", + }, + }, + }) + .mockImplementation(() => new Promise(() => {})); + + const { monitorZaloProvider } = await import("./monitor.js"); + const abort = new AbortController(); + const runtime = createRuntimeEnv(); + const run = monitorZaloProvider({ + token: "zalo-token", // pragma: allowlist secret + account: TEST_ACCOUNT, + config: TEST_CONFIG, + runtime, + abortSignal: abort.signal, + }); + + await vi.waitFor(() => + expect(fetchRemoteMediaMock).toHaveBeenCalledWith({ + url: "https://example.com/test-image.jpg", + maxBytes: 5 * 1024 * 1024, + }), + ); + expect(saveMediaBufferMock).toHaveBeenCalledTimes(1); + expect(finalizeInboundContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + SenderName: "Test User", + MediaPath: "/tmp/zalo-photo.jpg", + MediaType: "image/jpeg", + }), + ); + expect(recordInboundSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + SenderName: "Test User", + MediaPath: "/tmp/zalo-photo.jpg", + MediaType: "image/jpeg", + }), + }), + ); + + abort.abort(); + await run; + }); +}); diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index ad36b1f27d5..7f0ae5e3385 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -284,15 +284,15 @@ async function handleTextMessage( async function handleImageMessage(params: ZaloImageMessageParams): Promise { const { message, mediaMaxMb, account, core, runtime } = params; - const { photo, caption } = message; + const { photo_url, caption } = message; let mediaPath: string | undefined; let mediaType: string | undefined; - if (photo) { + if (photo_url) { try { const maxBytes = mediaMaxMb * 1024 * 1024; - const fetched = await core.channel.media.fetchRemoteMedia({ url: photo, maxBytes }); + const fetched = await core.channel.media.fetchRemoteMedia({ url: photo_url, maxBytes }); const saved = await core.channel.media.saveMediaBuffer( fetched.buffer, fetched.contentType, @@ -338,7 +338,7 @@ async function processMessageWithPipeline(params: ZaloMessagePipelineParams): Pr const isGroup = chat.chat_type === "GROUP"; const chatId = chat.id; const senderId = from.id; - const senderName = from.name; + const senderName = from.display_name ?? from.name; const dmPolicy = account.config.dmPolicy ?? "pairing"; const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v)); diff --git a/extensions/zalo/src/monitor.webhook.test.ts b/extensions/zalo/src/monitor.webhook.test.ts index a66bc455cf4..318aff6a64e 100644 --- a/extensions/zalo/src/monitor.webhook.test.ts +++ b/extensions/zalo/src/monitor.webhook.test.ts @@ -3,6 +3,7 @@ import type { AddressInfo } from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js"; import { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; +import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js"; import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js"; import { clearZaloWebhookSecurityStateForTest, @@ -261,6 +262,115 @@ describe("handleZaloWebhookRequest", () => { } }); + it("downloads inbound image media from webhook photo_url and preserves display_name", async () => { + const finalizeInboundContextMock = vi.fn((ctx: Record) => ctx); + const recordInboundSessionMock = vi.fn(async () => undefined); + const fetchRemoteMediaMock = vi.fn(async () => ({ + buffer: Buffer.from("image-bytes"), + contentType: "image/jpeg", + })); + const saveMediaBufferMock = vi.fn(async () => ({ + path: "/tmp/zalo-photo.jpg", + contentType: "image/jpeg", + })); + const core = createPluginRuntimeMock({ + channel: { + media: { + fetchRemoteMedia: + fetchRemoteMediaMock as unknown as PluginRuntime["channel"]["media"]["fetchRemoteMedia"], + saveMediaBuffer: + saveMediaBufferMock as unknown as PluginRuntime["channel"]["media"]["saveMediaBuffer"], + }, + reply: { + finalizeInboundContext: + finalizeInboundContextMock as unknown as PluginRuntime["channel"]["reply"]["finalizeInboundContext"], + dispatchReplyWithBufferedBlockDispatcher: vi.fn( + async () => undefined, + ) as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"], + }, + session: { + recordInboundSession: + recordInboundSessionMock as unknown as PluginRuntime["channel"]["session"]["recordInboundSession"], + }, + commands: { + shouldComputeCommandAuthorized: vi.fn( + () => false, + ) as unknown as PluginRuntime["channel"]["commands"]["shouldComputeCommandAuthorized"], + resolveCommandAuthorizedFromAuthorizers: vi.fn( + () => false, + ) as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"], + isControlCommandMessage: vi.fn( + () => false, + ) as unknown as PluginRuntime["channel"]["commands"]["isControlCommandMessage"], + }, + }, + }); + const unregister = registerTarget({ + path: "/hook-image", + core, + account: { + ...DEFAULT_ACCOUNT, + config: { + dmPolicy: "open", + }, + }, + }); + + const payload = { + event_name: "message.image.received", + message: { + date: 1774086023728, + chat: { chat_type: "PRIVATE", id: "chat-123" }, + caption: "", + message_id: "msg-123", + message_type: "CHAT_PHOTO", + from: { id: "user-123", is_bot: false, display_name: "Test User" }, + photo_url: "https://example.com/test-image.jpg", + }, + }; + + try { + await withServer(webhookRequestHandler, async (baseUrl) => { + const response = await fetch(`${baseUrl}/hook-image`, { + method: "POST", + headers: { + "x-bot-api-secret-token": "secret", + "content-type": "application/json", + }, + body: JSON.stringify(payload), + }); + + expect(response.status).toBe(200); + }); + } finally { + unregister(); + } + + await vi.waitFor(() => + expect(fetchRemoteMediaMock).toHaveBeenCalledWith({ + url: "https://example.com/test-image.jpg", + maxBytes: 5 * 1024 * 1024, + }), + ); + expect(saveMediaBufferMock).toHaveBeenCalledTimes(1); + expect(finalizeInboundContextMock).toHaveBeenCalledWith( + expect.objectContaining({ + SenderName: "Test User", + MediaPath: "/tmp/zalo-photo.jpg", + MediaType: "image/jpeg", + }), + ); + expect(recordInboundSessionMock).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + SenderName: "Test User", + MediaPath: "/tmp/zalo-photo.jpg", + MediaType: "image/jpeg", + }), + }), + ); + }); + it("returns 429 when per-path request rate exceeds threshold", async () => { const unregister = registerTarget({ path: "/hook-rate" });