mirror of https://github.com/openclaw/openclaw.git
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:
parent
3f7f2c8dc9
commit
cdf49f0b00
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue