zalo: use photo_url for inbound images (#51543)

* Zalo: use photo_url for inbound images

* Tests: wait for Zalo webhook image processing
This commit is contained in:
darkamenosa
2026-03-21 17:21:44 +07:00
committed by GitHub
parent 3f7f2c8dc9
commit cdf49f0b00
4 changed files with 295 additions and 5 deletions

View File

@@ -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 = {

View File

@@ -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<typeof import("./api.js")>();
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<string, unknown>) => 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;
});
});

View File

@@ -284,15 +284,15 @@ async function handleTextMessage(
async function handleImageMessage(params: ZaloImageMessageParams): Promise<void> {
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));

View File

@@ -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<string, unknown>) => 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" });