mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-24 00:11:31 +00:00
276 lines
7.4 KiB
TypeScript
276 lines
7.4 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { logVerbose } from "../../globals.js";
|
|
import { sleep } from "../../utils.js";
|
|
import { loadWebMedia } from "../media.js";
|
|
import { deliverWebReply } from "./deliver-reply.js";
|
|
import type { WebInboundMsg } from "./types.js";
|
|
|
|
vi.mock("../../globals.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../../globals.js")>();
|
|
return {
|
|
...actual,
|
|
shouldLogVerbose: vi.fn(() => true),
|
|
logVerbose: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock("../media.js", () => ({
|
|
loadWebMedia: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../utils.js", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../../utils.js")>();
|
|
return {
|
|
...actual,
|
|
sleep: vi.fn(async () => {}),
|
|
};
|
|
});
|
|
|
|
function makeMsg(): WebInboundMsg {
|
|
return {
|
|
from: "+10000000000",
|
|
to: "+20000000000",
|
|
id: "msg-1",
|
|
reply: vi.fn(async () => undefined),
|
|
sendMedia: vi.fn(async () => undefined),
|
|
} as unknown as WebInboundMsg;
|
|
}
|
|
|
|
function mockLoadedImageMedia() {
|
|
(
|
|
loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
|
|
).mockResolvedValueOnce({
|
|
buffer: Buffer.from("img"),
|
|
contentType: "image/jpeg",
|
|
kind: "image",
|
|
});
|
|
}
|
|
|
|
function mockFirstSendMediaFailure(msg: WebInboundMsg, message: string) {
|
|
(
|
|
msg.sendMedia as unknown as { mockRejectedValueOnce: (v: unknown) => void }
|
|
).mockRejectedValueOnce(new Error(message));
|
|
}
|
|
|
|
function mockFirstReplyFailure(msg: WebInboundMsg, message: string) {
|
|
(msg.reply as unknown as { mockRejectedValueOnce: (v: unknown) => void }).mockRejectedValueOnce(
|
|
new Error(message),
|
|
);
|
|
}
|
|
|
|
function mockSecondReplySuccess(msg: WebInboundMsg) {
|
|
(msg.reply as unknown as { mockResolvedValueOnce: (v: unknown) => void }).mockResolvedValueOnce(
|
|
undefined,
|
|
);
|
|
}
|
|
|
|
const replyLogger = {
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
};
|
|
|
|
describe("deliverWebReply", () => {
|
|
it("sends chunked text replies and logs a summary", async () => {
|
|
const msg = makeMsg();
|
|
|
|
await deliverWebReply({
|
|
replyResult: { text: "aaaaaa" },
|
|
msg,
|
|
maxMediaBytes: 1024 * 1024,
|
|
textLimit: 3,
|
|
replyLogger,
|
|
skipLog: true,
|
|
});
|
|
|
|
expect(msg.reply).toHaveBeenCalledTimes(2);
|
|
expect(msg.reply).toHaveBeenNthCalledWith(1, "aaa");
|
|
expect(msg.reply).toHaveBeenNthCalledWith(2, "aaa");
|
|
expect(replyLogger.info).toHaveBeenCalledWith(expect.any(Object), "auto-reply sent (text)");
|
|
});
|
|
|
|
it.each(["connection closed", "operation timed out"])(
|
|
"retries text send on transient failure: %s",
|
|
async (errorMessage) => {
|
|
const msg = makeMsg();
|
|
mockFirstReplyFailure(msg, errorMessage);
|
|
mockSecondReplySuccess(msg);
|
|
|
|
await deliverWebReply({
|
|
replyResult: { text: "hi" },
|
|
msg,
|
|
maxMediaBytes: 1024 * 1024,
|
|
textLimit: 200,
|
|
replyLogger,
|
|
skipLog: true,
|
|
});
|
|
|
|
expect(msg.reply).toHaveBeenCalledTimes(2);
|
|
expect(sleep).toHaveBeenCalledWith(500);
|
|
},
|
|
);
|
|
|
|
it("sends image media with caption and then remaining text", async () => {
|
|
const msg = makeMsg();
|
|
const mediaLocalRoots = ["/tmp/workspace-work"];
|
|
mockLoadedImageMedia();
|
|
|
|
await deliverWebReply({
|
|
replyResult: { text: "aaaaaa", mediaUrl: "http://example.com/img.jpg" },
|
|
msg,
|
|
mediaLocalRoots,
|
|
maxMediaBytes: 1024 * 1024,
|
|
textLimit: 3,
|
|
replyLogger,
|
|
skipLog: true,
|
|
});
|
|
|
|
expect(loadWebMedia).toHaveBeenCalledWith("http://example.com/img.jpg", {
|
|
maxBytes: 1024 * 1024,
|
|
localRoots: mediaLocalRoots,
|
|
});
|
|
|
|
expect(msg.sendMedia).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
image: expect.any(Buffer),
|
|
caption: "aaa",
|
|
mimetype: "image/jpeg",
|
|
}),
|
|
);
|
|
expect(msg.reply).toHaveBeenCalledWith("aaa");
|
|
expect(replyLogger.info).toHaveBeenCalledWith(expect.any(Object), "auto-reply sent (media)");
|
|
expect(logVerbose).toHaveBeenCalled();
|
|
});
|
|
|
|
it("retries media send on transient failure", async () => {
|
|
const msg = makeMsg();
|
|
mockLoadedImageMedia();
|
|
mockFirstSendMediaFailure(msg, "socket reset");
|
|
(
|
|
msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
|
|
).mockResolvedValueOnce(undefined);
|
|
|
|
await deliverWebReply({
|
|
replyResult: { text: "caption", mediaUrl: "http://example.com/img.jpg" },
|
|
msg,
|
|
maxMediaBytes: 1024 * 1024,
|
|
textLimit: 200,
|
|
replyLogger,
|
|
skipLog: true,
|
|
});
|
|
|
|
expect(msg.sendMedia).toHaveBeenCalledTimes(2);
|
|
expect(sleep).toHaveBeenCalledWith(500);
|
|
});
|
|
|
|
it("falls back to text-only when the first media send fails", async () => {
|
|
const msg = makeMsg();
|
|
mockLoadedImageMedia();
|
|
mockFirstSendMediaFailure(msg, "boom");
|
|
|
|
await deliverWebReply({
|
|
replyResult: { text: "caption", mediaUrl: "http://example.com/img.jpg" },
|
|
msg,
|
|
maxMediaBytes: 1024 * 1024,
|
|
textLimit: 20,
|
|
replyLogger,
|
|
skipLog: true,
|
|
});
|
|
|
|
expect(msg.reply).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
String((msg.reply as unknown as { mock: { calls: unknown[][] } }).mock.calls[0]?.[0]),
|
|
).toContain("⚠️ Media failed");
|
|
expect(replyLogger.warn).toHaveBeenCalledWith(
|
|
expect.objectContaining({ mediaUrl: "http://example.com/img.jpg" }),
|
|
"failed to send web media reply",
|
|
);
|
|
});
|
|
|
|
it("sends audio media as ptt voice note", async () => {
|
|
const msg = makeMsg();
|
|
(
|
|
loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
|
|
).mockResolvedValueOnce({
|
|
buffer: Buffer.from("aud"),
|
|
contentType: "audio/ogg",
|
|
kind: "audio",
|
|
});
|
|
|
|
await deliverWebReply({
|
|
replyResult: { text: "cap", mediaUrl: "http://example.com/a.ogg" },
|
|
msg,
|
|
maxMediaBytes: 1024 * 1024,
|
|
textLimit: 200,
|
|
replyLogger,
|
|
skipLog: true,
|
|
});
|
|
|
|
expect(msg.sendMedia).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
audio: expect.any(Buffer),
|
|
ptt: true,
|
|
mimetype: "audio/ogg",
|
|
caption: "cap",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("sends video media", async () => {
|
|
const msg = makeMsg();
|
|
(
|
|
loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
|
|
).mockResolvedValueOnce({
|
|
buffer: Buffer.from("vid"),
|
|
contentType: "video/mp4",
|
|
kind: "video",
|
|
});
|
|
|
|
await deliverWebReply({
|
|
replyResult: { text: "cap", mediaUrl: "http://example.com/v.mp4" },
|
|
msg,
|
|
maxMediaBytes: 1024 * 1024,
|
|
textLimit: 200,
|
|
replyLogger,
|
|
skipLog: true,
|
|
});
|
|
|
|
expect(msg.sendMedia).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
video: expect.any(Buffer),
|
|
caption: "cap",
|
|
mimetype: "video/mp4",
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("sends non-audio/image/video media as document", async () => {
|
|
const msg = makeMsg();
|
|
(
|
|
loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
|
|
).mockResolvedValueOnce({
|
|
buffer: Buffer.from("bin"),
|
|
contentType: undefined,
|
|
kind: "file",
|
|
fileName: "x.bin",
|
|
});
|
|
|
|
await deliverWebReply({
|
|
replyResult: { text: "cap", mediaUrl: "http://example.com/x.bin" },
|
|
msg,
|
|
maxMediaBytes: 1024 * 1024,
|
|
textLimit: 200,
|
|
replyLogger,
|
|
skipLog: true,
|
|
});
|
|
|
|
expect(msg.sendMedia).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
document: expect.any(Buffer),
|
|
fileName: "x.bin",
|
|
caption: "cap",
|
|
mimetype: "application/octet-stream",
|
|
}),
|
|
);
|
|
});
|
|
});
|