mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-07 18:42:25 +00:00
test(whatsapp): update auto reply inbound fixtures
This commit is contained in:
@@ -7,13 +7,14 @@ import {
|
||||
sendWebDirectInboundAndCollectSessionKeys,
|
||||
} from "./auto-reply.broadcast-groups.test-harness.js";
|
||||
import {
|
||||
createAcceptedWhatsAppSendResult,
|
||||
createWebInboundDeliverySpies,
|
||||
installWebAutoReplyTestHomeHooks,
|
||||
installWebAutoReplyUnitTestHooks,
|
||||
resetLoadConfigMock,
|
||||
sendWebGroupInboundMessage,
|
||||
setLoadConfigMock,
|
||||
} from "./auto-reply.test-harness.js";
|
||||
import { createTestWebInboundMessage } from "./inbound/test-message.test-helper.js";
|
||||
|
||||
installWebAutoReplyTestHomeHooks();
|
||||
|
||||
@@ -204,9 +205,7 @@ describe("broadcast groups", () => {
|
||||
},
|
||||
} satisfies OpenClawConfig);
|
||||
|
||||
const sendMedia = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1"));
|
||||
const reply = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1"));
|
||||
const sendComposing = vi.fn();
|
||||
const { sendMedia, reply, sendComposing } = createWebInboundDeliverySpies();
|
||||
|
||||
let started = 0;
|
||||
let release: (() => void) | undefined;
|
||||
@@ -226,20 +225,28 @@ describe("broadcast groups", () => {
|
||||
|
||||
const { onMessage: capturedOnMessage } = await monitorWebChannelWithCapture(resolver);
|
||||
|
||||
await capturedOnMessage({
|
||||
id: "m1",
|
||||
from: "+1000",
|
||||
conversationId: "+1000",
|
||||
to: "+2000",
|
||||
accountId: "default",
|
||||
body: "hello",
|
||||
timestamp: Date.now(),
|
||||
chatType: "direct",
|
||||
chatId: "direct:+1000",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
await capturedOnMessage(
|
||||
createTestWebInboundMessage({
|
||||
event: {
|
||||
id: "m1",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
payload: {
|
||||
body: "hello",
|
||||
},
|
||||
platform: {
|
||||
chatJid: "direct:+1000",
|
||||
recipientJid: "+2000",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
},
|
||||
from: "+1000",
|
||||
conversationId: "+1000",
|
||||
accountId: "default",
|
||||
chatType: "direct",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(resolver).toHaveBeenCalledTimes(2);
|
||||
resetLoadConfigMock();
|
||||
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
sendWebDirectInboundMessage,
|
||||
} from "./auto-reply.test-harness.js";
|
||||
import { monitorWebChannel } from "./auto-reply/monitor.js";
|
||||
import type { WebInboundMessage } from "./inbound.js";
|
||||
import type { WebInboundMessageInput } from "./inbound.js";
|
||||
|
||||
export async function monitorWebChannelWithCapture(resolver: unknown): Promise<{
|
||||
spies: ReturnType<typeof createWebInboundDeliverySpies>;
|
||||
onMessage: (msg: WebInboundMessage) => Promise<void>;
|
||||
onMessage: (msg: WebInboundMessageInput) => Promise<void>;
|
||||
}> {
|
||||
const spies = createWebInboundDeliverySpies();
|
||||
const { listenerFactory, getOnMessage } = createWebListenerFactoryCapture();
|
||||
|
||||
@@ -9,13 +9,16 @@ import { resetLogger, setLoggerOverride } from "openclaw/plugin-sdk/runtime-env"
|
||||
import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, vi, type Mock } from "vitest";
|
||||
import type { WebChannelStatus } from "./auto-reply/types.js";
|
||||
import type { WebInboundMessage, WebListenerCloseReason } from "./inbound.js";
|
||||
import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js";
|
||||
import type { WebInboundMessageInput, WebListenerCloseReason } from "./inbound.js";
|
||||
import type { WhatsAppSendResult } from "./inbound/send-result.js";
|
||||
import { createAcceptedWhatsAppSendResult as createAcceptedWhatsAppSendResultForHarness } from "./inbound/send-result.test-helper.js";
|
||||
import { createTestWebInboundMessage } from "./inbound/test-message.test-helper.js";
|
||||
import {
|
||||
resetBaileysMocks as _resetBaileysMocks,
|
||||
resetLoadConfigMock as _resetLoadConfigMock,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
export { createAcceptedWhatsAppSendResult } from "./inbound/send-result.test-helper.js";
|
||||
export {
|
||||
resetLoadConfigMock,
|
||||
setLoadConfigMock,
|
||||
@@ -235,16 +238,18 @@ export function installWebAutoReplyUnitTestHooks(opts?: { pinDns?: boolean }) {
|
||||
}
|
||||
|
||||
export function createWebListenerFactoryCapture(): AnyExport {
|
||||
let capturedOnMessage: ((msg: WebInboundMessage) => Promise<void>) | undefined;
|
||||
let capturedOnMessage: ((msg: WebInboundMessageInput) => Promise<void>) | undefined;
|
||||
let capturedOptions:
|
||||
| {
|
||||
onMessage: (msg: WebInboundMessage) => Promise<void>;
|
||||
onMessage: (msg: WebInboundMessageInput) => Promise<void>;
|
||||
shouldDebounce?: (msg: WebInboundMessageInput) => boolean;
|
||||
debounceMs?: number;
|
||||
selfChatMode?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
const listenerFactory = async (opts: {
|
||||
onMessage: (msg: WebInboundMessage) => Promise<void>;
|
||||
onMessage: (msg: WebInboundMessageInput) => Promise<void>;
|
||||
shouldDebounce?: (msg: WebInboundMessageInput) => boolean;
|
||||
debounceMs?: number;
|
||||
selfChatMode?: boolean;
|
||||
}) => {
|
||||
@@ -265,35 +270,31 @@ export function createMockWebListener(): MockWebListener {
|
||||
close: vi.fn(async () => undefined),
|
||||
onClose: new Promise<WebListenerCloseReason>(() => {}),
|
||||
signalClose: vi.fn(),
|
||||
sendMessage: vi.fn(async () => createAcceptedWhatsAppSendResult("text", "msg-1")),
|
||||
sendPoll: vi.fn(async () => createAcceptedWhatsAppSendResult("poll", "poll-1")),
|
||||
sendContact: vi.fn(async () => createAcceptedWhatsAppSendResult("contact", "contact-1")),
|
||||
sendLocation: vi.fn(async () => createAcceptedWhatsAppSendResult("location", "location-1")),
|
||||
sendSticker: vi.fn(async () => createAcceptedWhatsAppSendResult("sticker", "sticker-1")),
|
||||
sendReaction: vi.fn(async () => createAcceptedWhatsAppSendResult("reaction", "reaction-1")),
|
||||
sendMessage: vi.fn(async () => createAcceptedWhatsAppSendResultForHarness("text", "msg-1")),
|
||||
sendPoll: vi.fn(async () => createAcceptedWhatsAppSendResultForHarness("poll", "poll-1")),
|
||||
sendContact: vi.fn(async () =>
|
||||
createAcceptedWhatsAppSendResultForHarness("contact", "contact-1"),
|
||||
),
|
||||
sendLocation: vi.fn(async () =>
|
||||
createAcceptedWhatsAppSendResultForHarness("location", "location-1"),
|
||||
),
|
||||
sendSticker: vi.fn(async () =>
|
||||
createAcceptedWhatsAppSendResultForHarness("sticker", "sticker-1"),
|
||||
),
|
||||
sendReaction: vi.fn(async () =>
|
||||
createAcceptedWhatsAppSendResultForHarness("reaction", "reaction-1"),
|
||||
),
|
||||
sendComposingTo: vi.fn(async () => undefined),
|
||||
};
|
||||
}
|
||||
|
||||
export function createAcceptedWhatsAppSendResult(
|
||||
kind: WhatsAppSendKind,
|
||||
id: string,
|
||||
): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function createScriptedWebListenerFactory(): AnyExport {
|
||||
const onMessages: Array<(msg: WebInboundMessage) => Promise<void>> = [];
|
||||
const onMessages: Array<(msg: WebInboundMessageInput) => Promise<void>> = [];
|
||||
const closeResolvers: Array<(reason: unknown) => void> = [];
|
||||
const listeners: MockWebListener[] = [];
|
||||
|
||||
const listenerFactory = vi.fn(
|
||||
async (opts: { onMessage: (msg: WebInboundMessage) => Promise<void> }) => {
|
||||
async (opts: { onMessage: (msg: WebInboundMessageInput) => Promise<void> }) => {
|
||||
onMessages.push(opts.onMessage);
|
||||
let resolveClose: (reason: unknown) => void = () => {};
|
||||
const onClose = new Promise<WebListenerCloseReason>((res) => {
|
||||
@@ -321,8 +322,8 @@ export function createScriptedWebListenerFactory(): AnyExport {
|
||||
|
||||
export function createWebInboundDeliverySpies(): AnyExport {
|
||||
return {
|
||||
sendMedia: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1")),
|
||||
reply: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1")),
|
||||
sendMedia: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResultForHarness("media", "m1")),
|
||||
reply: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResultForHarness("text", "r1")),
|
||||
sendComposing: vi.fn(),
|
||||
};
|
||||
}
|
||||
@@ -373,7 +374,7 @@ export function startWebAutoReplyMonitor(params: {
|
||||
}
|
||||
|
||||
export async function sendWebGroupInboundMessage(params: {
|
||||
onMessage: (msg: WebInboundMessage) => Promise<void>;
|
||||
onMessage: (msg: WebInboundMessageInput) => Promise<void>;
|
||||
body: string;
|
||||
id: string;
|
||||
senderE164: string;
|
||||
@@ -387,28 +388,38 @@ export async function sendWebGroupInboundMessage(params: {
|
||||
}) {
|
||||
const conversationId = params.conversationId ?? "123@g.us";
|
||||
const accountId = params.accountId ?? "default";
|
||||
await params.onMessage({
|
||||
body: params.body,
|
||||
from: conversationId,
|
||||
conversationId,
|
||||
chatId: conversationId,
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
accountId,
|
||||
id: params.id,
|
||||
senderE164: params.senderE164,
|
||||
senderName: params.senderName,
|
||||
mentionedJids: params.mentionedJids,
|
||||
selfE164: params.selfE164,
|
||||
selfJid: params.selfJid,
|
||||
sendComposing: params.spies.sendComposing,
|
||||
reply: params.spies.reply,
|
||||
sendMedia: params.spies.sendMedia,
|
||||
} as WebInboundMessage);
|
||||
await params.onMessage(
|
||||
createTestWebInboundMessage({
|
||||
event: { id: params.id },
|
||||
payload: { body: params.body },
|
||||
platform: {
|
||||
chatJid: conversationId,
|
||||
recipientJid: "+2",
|
||||
senderE164: params.senderE164,
|
||||
senderName: params.senderName,
|
||||
selfE164: params.selfE164,
|
||||
selfJid: params.selfJid,
|
||||
sendComposing: params.spies.sendComposing,
|
||||
reply: params.spies.reply,
|
||||
sendMedia: params.spies.sendMedia,
|
||||
},
|
||||
from: conversationId,
|
||||
conversationId,
|
||||
chatType: "group",
|
||||
accountId,
|
||||
group: params.mentionedJids?.length
|
||||
? {
|
||||
mentions: {
|
||||
jids: params.mentionedJids,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendWebDirectInboundMessage(params: {
|
||||
onMessage: (msg: WebInboundMessage) => Promise<void>;
|
||||
onMessage: (msg: WebInboundMessageInput) => Promise<void>;
|
||||
body: string;
|
||||
id: string;
|
||||
from: string;
|
||||
@@ -418,18 +429,26 @@ export async function sendWebDirectInboundMessage(params: {
|
||||
timestamp?: number;
|
||||
}) {
|
||||
const accountId = params.accountId ?? "default";
|
||||
await params.onMessage({
|
||||
accountId,
|
||||
id: params.id,
|
||||
from: params.from,
|
||||
conversationId: params.from,
|
||||
to: params.to,
|
||||
body: params.body,
|
||||
timestamp: params.timestamp ?? Date.now(),
|
||||
chatType: "direct",
|
||||
chatId: `direct:${params.from}`,
|
||||
sendComposing: params.spies.sendComposing,
|
||||
reply: params.spies.reply,
|
||||
sendMedia: params.spies.sendMedia,
|
||||
} as WebInboundMessage);
|
||||
await params.onMessage(
|
||||
createTestWebInboundMessage({
|
||||
accountId,
|
||||
event: {
|
||||
id: params.id,
|
||||
timestamp: params.timestamp ?? Date.now(),
|
||||
},
|
||||
payload: {
|
||||
body: params.body,
|
||||
},
|
||||
platform: {
|
||||
chatJid: `direct:${params.from}`,
|
||||
recipientJid: params.to,
|
||||
sendComposing: params.spies.sendComposing,
|
||||
reply: params.spies.reply,
|
||||
sendMedia: params.spies.sendMedia,
|
||||
},
|
||||
from: params.from,
|
||||
conversationId: params.from,
|
||||
chatType: "direct",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,14 @@ import { createNoisyPngBuffer, createSolidPngBuffer } from "openclaw/plugin-sdk/
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createMockWebListener,
|
||||
createAcceptedWhatsAppSendResult,
|
||||
createWebInboundDeliverySpies,
|
||||
installWebAutoReplyTestHomeHooks,
|
||||
installWebAutoReplyUnitTestHooks,
|
||||
resetLoadConfigMock,
|
||||
setLoadConfigMock,
|
||||
} from "./auto-reply.test-harness.js";
|
||||
import type { WebInboundMessage } from "./inbound.js";
|
||||
import type { WebInboundCallbackMessage, WebInboundMessageInput } from "./inbound.js";
|
||||
import { createTestWebInboundMessage } from "./inbound/test-message.test-helper.js";
|
||||
|
||||
installWebAutoReplyTestHomeHooks();
|
||||
|
||||
@@ -19,6 +20,10 @@ let monitorWebChannel: typeof import("./auto-reply/monitor.js").monitorWebChanne
|
||||
describe("web auto-reply", () => {
|
||||
installWebAutoReplyUnitTestHooks({ pinDns: true });
|
||||
type ListenerFactory = NonNullable<Parameters<typeof monitorWebChannel>[1]>;
|
||||
type WebInboundPlatform = WebInboundCallbackMessage["platform"];
|
||||
type ReplyMock = ReturnType<typeof vi.fn<WebInboundPlatform["reply"]>>;
|
||||
type SendMediaMock = ReturnType<typeof vi.fn<WebInboundPlatform["sendMedia"]>>;
|
||||
type SendComposingMock = ReturnType<typeof vi.fn<WebInboundPlatform["sendComposing"]>>;
|
||||
const SMALL_MEDIA_CAP_MB = 0.1;
|
||||
const SMALL_MEDIA_CAP_BYTES = Math.floor(SMALL_MEDIA_CAP_MB * 1024 * 1024);
|
||||
|
||||
@@ -28,15 +33,19 @@ describe("web auto-reply", () => {
|
||||
|
||||
async function setupSingleInboundMessage(params: {
|
||||
resolverValue: { text: string; mediaUrl: string };
|
||||
sendMedia: ReturnType<typeof vi.fn>;
|
||||
reply?: ReturnType<typeof vi.fn>;
|
||||
sendMedia?: SendMediaMock;
|
||||
reply?: ReplyMock;
|
||||
}) {
|
||||
const reply =
|
||||
params.reply ?? vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1"));
|
||||
const sendComposing = vi.fn(async () => undefined);
|
||||
const spies = createWebInboundDeliverySpies() as {
|
||||
sendMedia: SendMediaMock;
|
||||
reply: ReplyMock;
|
||||
sendComposing: SendComposingMock;
|
||||
};
|
||||
const reply = params.reply ?? spies.reply;
|
||||
const sendMedia = params.sendMedia ?? spies.sendMedia;
|
||||
const resolver = vi.fn().mockResolvedValue(params.resolverValue);
|
||||
|
||||
let capturedOnMessage: ((msg: WebInboundMessage) => Promise<void>) | undefined;
|
||||
let capturedOnMessage: ((msg: WebInboundMessageInput) => Promise<void>) | undefined;
|
||||
const listenerFactory: ListenerFactory = async ({ onMessage }) => {
|
||||
capturedOnMessage = onMessage;
|
||||
return createMockWebListener();
|
||||
@@ -50,26 +59,41 @@ describe("web auto-reply", () => {
|
||||
|
||||
return {
|
||||
reply,
|
||||
sendMedia,
|
||||
dispatch: async (
|
||||
id = "msg1",
|
||||
overrides?: Partial<
|
||||
Pick<WebInboundMessage, "from" | "conversationId" | "to" | "accountId" | "chatId">
|
||||
>,
|
||||
overrides?: Partial<{
|
||||
from: string;
|
||||
conversationId: string;
|
||||
accountId: string;
|
||||
recipientJid: string;
|
||||
chatJid: string;
|
||||
}>,
|
||||
) => {
|
||||
await onMessage({
|
||||
body: "hello",
|
||||
from: "+1",
|
||||
conversationId: "+1",
|
||||
to: "+2",
|
||||
accountId: "default",
|
||||
chatType: "direct",
|
||||
chatId: "+1",
|
||||
...overrides,
|
||||
id,
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia: params.sendMedia,
|
||||
} as WebInboundMessage);
|
||||
const from = overrides?.from ?? "+1";
|
||||
const conversationId = overrides?.conversationId ?? from;
|
||||
const chatJid = overrides?.chatJid ?? from;
|
||||
await onMessage(
|
||||
createTestWebInboundMessage({
|
||||
event: {
|
||||
id,
|
||||
},
|
||||
payload: {
|
||||
body: "hello",
|
||||
},
|
||||
platform: {
|
||||
chatJid,
|
||||
recipientJid: overrides?.recipientJid ?? "+2",
|
||||
sendComposing: spies.sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
},
|
||||
from,
|
||||
conversationId,
|
||||
accountId: overrides?.accountId ?? "default",
|
||||
chatType: "direct",
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -115,15 +139,21 @@ describe("web auto-reply", () => {
|
||||
}
|
||||
}
|
||||
|
||||
function mockFetchMediaBuffer(buffer: Buffer, mime: string) {
|
||||
return vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
body: true,
|
||||
function fetchResponse(body: Buffer | null, mime: string, status = 200): Response {
|
||||
return {
|
||||
ok: status < 400,
|
||||
body: body ? true : null,
|
||||
arrayBuffer: async () =>
|
||||
buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength),
|
||||
body
|
||||
? body.buffer.slice(body.byteOffset, body.byteOffset + body.length)
|
||||
: new ArrayBuffer(0),
|
||||
headers: new Headers({ "content-type": mime }),
|
||||
status: 200,
|
||||
} as unknown as Response);
|
||||
status,
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
function mockFetchMediaBuffer(buffer: Buffer, mime: string) {
|
||||
return vi.spyOn(globalThis, "fetch").mockResolvedValue(fetchResponse(buffer, mime));
|
||||
}
|
||||
|
||||
async function expectCompressedImageWithinCap(params: {
|
||||
@@ -134,10 +164,8 @@ describe("web auto-reply", () => {
|
||||
mediaMaxMb?: number;
|
||||
}) {
|
||||
await withMediaCap(params.mediaMaxMb ?? 1, async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const { reply, dispatch } = await setupSingleInboundMessage({
|
||||
const { reply, dispatch, sendMedia } = await setupSingleInboundMessage({
|
||||
resolverValue: { text: "hi", mediaUrl: params.mediaUrl },
|
||||
sendMedia,
|
||||
});
|
||||
const fetchMock = mockFetchMediaBuffer(params.image, params.mime);
|
||||
|
||||
@@ -173,13 +201,11 @@ describe("web auto-reply", () => {
|
||||
] as const;
|
||||
|
||||
await withMediaCap(1, async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const { reply, dispatch } = await setupSingleInboundMessage({
|
||||
const { reply, dispatch, sendMedia } = await setupSingleInboundMessage({
|
||||
resolverValue: {
|
||||
text: "hi",
|
||||
mediaUrl: "https://example.com/big.image",
|
||||
},
|
||||
sendMedia,
|
||||
});
|
||||
let fetchIndex = 0;
|
||||
|
||||
@@ -187,14 +213,7 @@ describe("web auto-reply", () => {
|
||||
const matched = formats[Math.min(fetchIndex, formats.length - 1)] ?? formats[0];
|
||||
fetchIndex += 1;
|
||||
const { image, mime } = matched;
|
||||
return {
|
||||
ok: true,
|
||||
body: true,
|
||||
arrayBuffer: async () =>
|
||||
image.buffer.slice(image.byteOffset, image.byteOffset + image.byteLength),
|
||||
headers: new Headers({ "content-type": mime }),
|
||||
status: 200,
|
||||
} as unknown as Response;
|
||||
return fetchResponse(image, mime);
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -203,7 +222,7 @@ describe("web auto-reply", () => {
|
||||
await dispatch(`msg-${fmt.name}-${index}`, {
|
||||
from: `+1${index}`,
|
||||
conversationId: `conv-${index}`,
|
||||
chatId: `conv-${index}`,
|
||||
chatJid: `conv-${index}`,
|
||||
});
|
||||
expect(sendMedia).toHaveBeenCalledTimes(beforeCalls + 1);
|
||||
const payload = imagePayloadAt(sendMedia, beforeCalls);
|
||||
@@ -250,10 +269,8 @@ describe("web auto-reply", () => {
|
||||
}));
|
||||
|
||||
try {
|
||||
const sendMedia = vi.fn();
|
||||
const { reply, dispatch } = await setupSingleInboundMessage({
|
||||
const { reply, dispatch, sendMedia } = await setupSingleInboundMessage({
|
||||
resolverValue: { text: "hi", mediaUrl: "https://example.com/account-big.png" },
|
||||
sendMedia,
|
||||
});
|
||||
const fetchMock = mockFetchMediaBuffer(bigPng, "image/png");
|
||||
|
||||
@@ -269,10 +286,8 @@ describe("web auto-reply", () => {
|
||||
}
|
||||
});
|
||||
it("sends PDF media as a document", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const { reply, dispatch } = await setupSingleInboundMessage({
|
||||
const { reply, dispatch, sendMedia } = await setupSingleInboundMessage({
|
||||
resolverValue: { text: "hi", mediaUrl: "https://example.com/file.pdf" },
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
const fetchMock = mockFetchMediaBuffer(Buffer.from("%PDF-1.4"), "application/pdf");
|
||||
@@ -294,7 +309,7 @@ describe("web auto-reply", () => {
|
||||
});
|
||||
|
||||
it("falls back to text when media send fails", async () => {
|
||||
const sendMedia = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
const sendMedia = vi.fn<WebInboundPlatform["sendMedia"]>().mockRejectedValue(new Error("boom"));
|
||||
const { reply, dispatch } = await setupSingleInboundMessage({
|
||||
resolverValue: {
|
||||
text: "hi",
|
||||
@@ -304,14 +319,7 @@ describe("web auto-reply", () => {
|
||||
});
|
||||
|
||||
const smallPng = createSolidPngBuffer(64, 64, { r: 0, g: 255, b: 0 });
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
body: true,
|
||||
arrayBuffer: async () =>
|
||||
smallPng.buffer.slice(smallPng.byteOffset, smallPng.byteOffset + smallPng.byteLength),
|
||||
headers: new Headers({ "content-type": "image/png" }),
|
||||
status: 200,
|
||||
} as unknown as Response);
|
||||
const fetchMock = mockFetchMediaBuffer(smallPng, "image/png");
|
||||
|
||||
await dispatch("msg1");
|
||||
|
||||
@@ -322,22 +330,16 @@ describe("web auto-reply", () => {
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
it("returns a warning when remote media fetch 404s", async () => {
|
||||
const sendMedia = vi.fn();
|
||||
const { reply, dispatch } = await setupSingleInboundMessage({
|
||||
const { reply, dispatch, sendMedia } = await setupSingleInboundMessage({
|
||||
resolverValue: {
|
||||
text: "caption",
|
||||
mediaUrl: "https://example.com/missing.jpg",
|
||||
},
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
body: null,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
headers: new Headers({ "content-type": "text/plain" }),
|
||||
} as unknown as Response);
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(fetchResponse(null, "text/plain", 404));
|
||||
|
||||
await dispatch("msg1");
|
||||
|
||||
@@ -350,24 +352,16 @@ describe("web auto-reply", () => {
|
||||
fetchMock.mockRestore();
|
||||
});
|
||||
it("sends media with a caption when delivery succeeds", async () => {
|
||||
const sendMedia = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1"));
|
||||
const { reply, dispatch } = await setupSingleInboundMessage({
|
||||
const { reply, dispatch, sendMedia } = await setupSingleInboundMessage({
|
||||
resolverValue: {
|
||||
text: "hi",
|
||||
mediaUrl: "https://example.com/img.png",
|
||||
},
|
||||
sendMedia,
|
||||
});
|
||||
|
||||
const png = createSolidPngBuffer(64, 64, { r: 0, g: 0, b: 255 });
|
||||
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
body: true,
|
||||
arrayBuffer: async () => png.buffer.slice(png.byteOffset, png.byteOffset + png.byteLength),
|
||||
headers: new Headers({ "content-type": "image/png" }),
|
||||
status: 200,
|
||||
} as unknown as Response);
|
||||
const fetchMock = mockFetchMediaBuffer(png, "image/png");
|
||||
|
||||
await dispatch("msg1");
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import { WhatsAppAuthUnstableError, resolveWebCredsPath } from "./auth-store.js"
|
||||
import { resolveOAuthDir } from "./auth-store.runtime.js";
|
||||
import {
|
||||
createWebInboundDeliverySpies,
|
||||
createAcceptedWhatsAppSendResult,
|
||||
createMockWebListener,
|
||||
createScriptedWebListenerFactory,
|
||||
createWebListenerFactoryCapture,
|
||||
@@ -28,6 +27,11 @@ import {
|
||||
startWebAutoReplyMonitor,
|
||||
} from "./auto-reply.test-harness.js";
|
||||
import { waitForWaConnection } from "./session.js";
|
||||
import {
|
||||
createTestLegacyFlatWebInboundMessage,
|
||||
createTestWebInboundMessage,
|
||||
} from "./inbound/test-message.test-helper.js";
|
||||
import type { WebInboundMessageInput } from "./inbound/types.js";
|
||||
|
||||
type DrainSelectionEntry = {
|
||||
channel: string;
|
||||
@@ -967,6 +971,35 @@ describe("web auto-reply connection", () => {
|
||||
expect(capture.getLastOptions()?.debounceMs).toBe(250);
|
||||
});
|
||||
|
||||
it("normalizes legacy flat listener messages and rejects partial nested input", async () => {
|
||||
const capture = createWebListenerFactoryCapture();
|
||||
const { reply } = createWebInboundDeliverySpies();
|
||||
|
||||
await monitorWebChannel(false, capture.listenerFactory as never, false, async () => ({
|
||||
text: "ok",
|
||||
}));
|
||||
const onMessage = requireOnMessage(capture.getOnMessage());
|
||||
const msg = createTestLegacyFlatWebInboundMessage({
|
||||
from: "+1",
|
||||
conversationId: "+1",
|
||||
chatId: "+1",
|
||||
to: "+2",
|
||||
reply,
|
||||
});
|
||||
|
||||
expect(capture.getLastOptions()?.shouldDebounce?.(msg)).toBe(true);
|
||||
await onMessage(msg);
|
||||
|
||||
expect(reply).toHaveBeenCalledWith("ok", undefined);
|
||||
await expect(
|
||||
onMessage({
|
||||
...msg,
|
||||
id: "partial-msg",
|
||||
payload: { body: "partial nested" },
|
||||
} as unknown as WebInboundMessageInput),
|
||||
).rejects.toThrow(/legacy flat or canonical nested/);
|
||||
});
|
||||
|
||||
it("processes inbound messages without batching and preserves timestamps", async () => {
|
||||
await withEnvAsync({ TZ: "Europe/Vienna" }, async () => {
|
||||
const originalMax = process.getMaxListeners();
|
||||
@@ -977,9 +1010,7 @@ describe("web auto-reply connection", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
const sendMedia = vi.fn();
|
||||
const reply = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1"));
|
||||
const sendComposing = vi.fn();
|
||||
const { sendMedia, reply, sendComposing } = createWebInboundDeliverySpies();
|
||||
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
|
||||
|
||||
const capture = createWebListenerFactoryCapture();
|
||||
@@ -1100,19 +1131,27 @@ describe("web auto-reply connection", () => {
|
||||
await monitorWebChannel(false, capture.listenerFactory as never, false, resolver as never);
|
||||
const capturedOnMessage = requireOnMessage(capture.getOnMessage());
|
||||
|
||||
await capturedOnMessage({
|
||||
body: "hello",
|
||||
from: "+1",
|
||||
conversationId: "+1",
|
||||
to: "+2",
|
||||
accountId: "default",
|
||||
chatType: "direct",
|
||||
chatId: "+1",
|
||||
id: "msg1",
|
||||
sendComposing: vi.fn(),
|
||||
reply: vi.fn(),
|
||||
sendMedia: vi.fn(),
|
||||
});
|
||||
await capturedOnMessage(
|
||||
createTestWebInboundMessage({
|
||||
event: {
|
||||
id: "msg1",
|
||||
},
|
||||
payload: {
|
||||
body: "hello",
|
||||
},
|
||||
platform: {
|
||||
chatJid: "+1",
|
||||
recipientJid: "+2",
|
||||
sendComposing: vi.fn(),
|
||||
reply: vi.fn(),
|
||||
sendMedia: vi.fn(),
|
||||
},
|
||||
from: "+1",
|
||||
conversationId: "+1",
|
||||
accountId: "default",
|
||||
chatType: "direct",
|
||||
}),
|
||||
);
|
||||
|
||||
const content = await fs.readFile(logPath, "utf-8");
|
||||
expect(content).toMatch(/web-auto-reply/);
|
||||
@@ -1131,9 +1170,7 @@ describe("web auto-reply connection", () => {
|
||||
markDispatchIdle,
|
||||
cleanup: vi.fn(),
|
||||
};
|
||||
const reply = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1"));
|
||||
const sendComposing = vi.fn().mockResolvedValue(undefined);
|
||||
const sendMedia = vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1"));
|
||||
const { reply, sendComposing, sendMedia } = createWebInboundDeliverySpies();
|
||||
|
||||
const replyResolver = vi.fn().mockImplementation(async (ctx, opts) => {
|
||||
void ctx;
|
||||
@@ -1150,20 +1187,28 @@ describe("web auto-reply connection", () => {
|
||||
await monitorWebChannel(
|
||||
false,
|
||||
async ({ onMessage }) => {
|
||||
await onMessage({
|
||||
id: "m1",
|
||||
from: "+1000",
|
||||
conversationId: "+1000",
|
||||
to: "+2000",
|
||||
body: "hello",
|
||||
timestamp: Date.now(),
|
||||
chatType: "direct",
|
||||
chatId: "direct:+1000",
|
||||
accountId: "default",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
});
|
||||
await onMessage(
|
||||
createTestWebInboundMessage({
|
||||
event: {
|
||||
id: "m1",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
payload: {
|
||||
body: "hello",
|
||||
},
|
||||
platform: {
|
||||
chatJid: "direct:+1000",
|
||||
recipientJid: "+2000",
|
||||
sendComposing,
|
||||
reply,
|
||||
sendMedia,
|
||||
},
|
||||
from: "+1000",
|
||||
conversationId: "+1000",
|
||||
chatType: "direct",
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
return createMockWebListener();
|
||||
},
|
||||
false,
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
import "./test-helpers.js";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAcceptedWhatsAppSendResult,
|
||||
installWebAutoReplyUnitTestHooks,
|
||||
makeSessionStore,
|
||||
} from "./auto-reply.test-harness.js";
|
||||
import { installWebAutoReplyUnitTestHooks, makeSessionStore } from "./auto-reply.test-harness.js";
|
||||
import { buildMentionConfig } from "./auto-reply/mentions.js";
|
||||
import { createEchoTracker } from "./auto-reply/monitor/echo.js";
|
||||
import { awaitBackgroundTasks } from "./auto-reply/monitor/last-route.js";
|
||||
import { createWebOnMessageHandler } from "./auto-reply/monitor/on-message.js";
|
||||
import { createTestWebInboundMessage } from "./inbound/test-message.test-helper.js";
|
||||
|
||||
const updateLastRouteInBackgroundMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -78,23 +75,26 @@ function buildInboundMessage(params: {
|
||||
senderName?: string;
|
||||
selfE164?: string;
|
||||
}) {
|
||||
return {
|
||||
id: params.id,
|
||||
return createTestWebInboundMessage({
|
||||
event: {
|
||||
id: params.id,
|
||||
timestamp: params.timestamp,
|
||||
},
|
||||
payload: {
|
||||
body: params.body ?? "hello",
|
||||
},
|
||||
platform: {
|
||||
chatJid: params.chatId,
|
||||
recipientJid: params.to ?? "+2000",
|
||||
senderE164: params.senderE164,
|
||||
senderName: params.senderName,
|
||||
selfE164: params.selfE164,
|
||||
},
|
||||
from: params.from,
|
||||
conversationId: params.conversationId,
|
||||
to: params.to ?? "+2000",
|
||||
body: params.body ?? "hello",
|
||||
timestamp: params.timestamp,
|
||||
chatType: params.chatType,
|
||||
chatId: params.chatId,
|
||||
accountId: params.accountId ?? "default",
|
||||
senderE164: params.senderE164,
|
||||
senderName: params.senderName,
|
||||
selfE164: params.selfE164,
|
||||
sendComposing: vi.fn().mockResolvedValue(undefined),
|
||||
reply: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("text", "r1")),
|
||||
sendMedia: vi.fn().mockResolvedValue(createAcceptedWhatsAppSendResult("media", "m1")),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe("web auto-reply last-route", () => {
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { sleep } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createAcceptedWhatsAppSendResult } from "../inbound/send-result.test-helper.js";
|
||||
import { createTestWebInboundMessage } from "../inbound/test-message.test-helper.js";
|
||||
import type { WebInboundMessage } from "../inbound/types.js";
|
||||
import { loadWebMedia } from "../media.js";
|
||||
import { cacheInboundMessageMeta } from "../quoted-message.js";
|
||||
import type { WebInboundMsg } from "./types.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
runFfmpeg: vi.fn(),
|
||||
@@ -53,19 +55,6 @@ vi.mock("../media.js", () => ({
|
||||
let deliverWebReply: typeof import("./deliver-reply.js").deliverWebReply;
|
||||
let whatsappOutbound: typeof import("../outbound-adapter.js").whatsappOutbound;
|
||||
|
||||
function acceptedSendResult(kind: "media" | "text", id: string) {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
receipt: createMessageReceiptFromOutboundResults({
|
||||
kind,
|
||||
results: [{ channel: "whatsapp", messageId: id }],
|
||||
}),
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function unacceptedSendResult(kind: "media" | "text") {
|
||||
return {
|
||||
kind,
|
||||
@@ -79,19 +68,22 @@ function unacceptedSendResult(kind: "media" | "text") {
|
||||
};
|
||||
}
|
||||
|
||||
function makeMsg(): WebInboundMsg {
|
||||
return {
|
||||
function makeMsg(): WebInboundMessage {
|
||||
return createTestWebInboundMessage({
|
||||
event: { id: "msg-1" },
|
||||
payload: { body: "latest batch body" },
|
||||
platform: {
|
||||
chatJid: "15551234567@s.whatsapp.net",
|
||||
recipientJid: "+20000000000",
|
||||
senderJid: "222@s.whatsapp.net",
|
||||
reply: vi.fn(async () => createAcceptedWhatsAppSendResult("text", "reply-sent-1")),
|
||||
sendMedia: vi.fn(async () => createAcceptedWhatsAppSendResult("media", "media-sent-1")),
|
||||
},
|
||||
from: "+10000000000",
|
||||
to: "+20000000000",
|
||||
accountId: "work",
|
||||
chatId: "15551234567@s.whatsapp.net",
|
||||
chatType: "group",
|
||||
id: "msg-1",
|
||||
body: "latest batch body",
|
||||
senderJid: "222@s.whatsapp.net",
|
||||
reply: vi.fn(async () => acceptedSendResult("text", "reply-sent-1")),
|
||||
sendMedia: vi.fn(async () => acceptedSendResult("media", "media-sent-1")),
|
||||
} as unknown as WebInboundMsg;
|
||||
conversationId: "+10000000000",
|
||||
});
|
||||
}
|
||||
|
||||
function mockLoadedImageMedia() {
|
||||
@@ -104,26 +96,28 @@ function mockLoadedImageMedia() {
|
||||
});
|
||||
}
|
||||
|
||||
function mockFirstSendMediaFailure(msg: WebInboundMsg, message: string) {
|
||||
function mockFirstSendMediaFailure(msg: WebInboundMessage, message: string) {
|
||||
(
|
||||
msg.sendMedia as unknown as { mockRejectedValueOnce: (v: unknown) => void }
|
||||
msg.platform.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 mockFirstReplyFailure(msg: WebInboundMessage, message: string) {
|
||||
(
|
||||
msg.platform.reply as unknown as { mockRejectedValueOnce: (v: unknown) => void }
|
||||
).mockRejectedValueOnce(new Error(message));
|
||||
}
|
||||
|
||||
function mockFirstReplyFailureWithWrappedError(msg: WebInboundMsg, message: string) {
|
||||
(msg.reply as unknown as { mockRejectedValueOnce: (v: unknown) => void }).mockRejectedValueOnce({
|
||||
function mockFirstReplyFailureWithWrappedError(msg: WebInboundMessage, message: string) {
|
||||
(
|
||||
msg.platform.reply as unknown as { mockRejectedValueOnce: (v: unknown) => void }
|
||||
).mockRejectedValueOnce({
|
||||
error: { message },
|
||||
});
|
||||
}
|
||||
|
||||
function expectFirstSendMediaPayload(msg: WebInboundMsg) {
|
||||
const payload = mockCallArg(msg.sendMedia, 0, 0, "sendMedia");
|
||||
function expectFirstSendMediaPayload(msg: WebInboundMessage) {
|
||||
const payload = mockCallArg(msg.platform.sendMedia, 0, 0, "sendMedia");
|
||||
if (!payload) {
|
||||
throw new Error("expected first WhatsApp sendMedia payload");
|
||||
}
|
||||
@@ -149,8 +143,8 @@ function mockCallArg(mock: unknown, callIndex: number, argIndex: number, label:
|
||||
return call[argIndex];
|
||||
}
|
||||
|
||||
function replyText(msg: WebInboundMsg, callIndex = 0): string {
|
||||
return String(mockCallArg(msg.reply, callIndex, 0, "reply"));
|
||||
function replyText(msg: WebInboundMessage, callIndex = 0): string {
|
||||
return String(mockCallArg(msg.platform.reply, callIndex, 0, "reply"));
|
||||
}
|
||||
|
||||
function findLoggerContext(mock: unknown, message: string, label: string) {
|
||||
@@ -181,10 +175,10 @@ function expectQuotedOptions(
|
||||
expect(quoted.message).toEqual({ conversation: expected.body });
|
||||
}
|
||||
|
||||
function mockSecondReplySuccess(msg: WebInboundMsg) {
|
||||
(msg.reply as unknown as { mockResolvedValueOnce: (v: unknown) => void }).mockResolvedValueOnce(
|
||||
acceptedSendResult("text", "reply-retry-2"),
|
||||
);
|
||||
function mockSecondReplySuccess(msg: WebInboundMessage) {
|
||||
(
|
||||
msg.platform.reply as unknown as { mockResolvedValueOnce: (v: unknown) => void }
|
||||
).mockResolvedValueOnce(createAcceptedWhatsAppSendResult("text", "reply-retry-2"));
|
||||
}
|
||||
|
||||
const replyLogger = {
|
||||
@@ -202,8 +196,8 @@ async function expectReplySuppressed(replyResult: { text: string; isReasoning?:
|
||||
replyLogger,
|
||||
skipLog: true,
|
||||
});
|
||||
expect(msg.reply).not.toHaveBeenCalled();
|
||||
expect(msg.sendMedia).not.toHaveBeenCalled();
|
||||
expect(msg.platform.reply).not.toHaveBeenCalled();
|
||||
expect(msg.platform.sendMedia).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
describe("deliverWebReply", () => {
|
||||
@@ -236,8 +230,8 @@ describe("deliverWebReply", () => {
|
||||
skipLog: true,
|
||||
});
|
||||
|
||||
expect(msg.reply).toHaveBeenCalledTimes(1);
|
||||
expect(msg.reply).toHaveBeenCalledWith(
|
||||
expect(msg.platform.reply).toHaveBeenCalledTimes(1);
|
||||
expect(msg.platform.reply).toHaveBeenCalledWith(
|
||||
"Intro line\nReasoning: appears in content but is not a prefix",
|
||||
undefined,
|
||||
);
|
||||
@@ -255,9 +249,9 @@ describe("deliverWebReply", () => {
|
||||
skipLog: true,
|
||||
});
|
||||
|
||||
expect(msg.reply).toHaveBeenCalledTimes(2);
|
||||
expect(msg.reply).toHaveBeenNthCalledWith(1, "aaa", undefined);
|
||||
expect(msg.reply).toHaveBeenNthCalledWith(2, "aaa", undefined);
|
||||
expect(msg.platform.reply).toHaveBeenCalledTimes(2);
|
||||
expect(msg.platform.reply).toHaveBeenNthCalledWith(1, "aaa", undefined);
|
||||
expect(msg.platform.reply).toHaveBeenNthCalledWith(2, "aaa", undefined);
|
||||
expect(typeof mockCallArg(replyLogger.info, 0, 0, "replyLogger.info")).toBe("object");
|
||||
expect(mockCallArg(replyLogger.info, 0, 1, "replyLogger.info")).toBe("auto-reply sent (text)");
|
||||
expect(delivery.providerAccepted).toBe(true);
|
||||
@@ -270,7 +264,7 @@ describe("deliverWebReply", () => {
|
||||
|
||||
it("reports text replies that Baileys did not accept", async () => {
|
||||
const msg = makeMsg();
|
||||
vi.mocked(msg.reply).mockResolvedValueOnce(unacceptedSendResult("text"));
|
||||
vi.mocked(msg.platform.reply).mockResolvedValueOnce(unacceptedSendResult("text"));
|
||||
|
||||
const delivery = await deliverWebReply({
|
||||
replyResult: { text: "hello" },
|
||||
@@ -281,7 +275,7 @@ describe("deliverWebReply", () => {
|
||||
skipLog: true,
|
||||
});
|
||||
|
||||
expect(msg.reply).toHaveBeenCalledTimes(1);
|
||||
expect(msg.platform.reply).toHaveBeenCalledTimes(1);
|
||||
expect(delivery.receipt.platformMessageIds).toEqual([]);
|
||||
expect(delivery.receipt.parts).toEqual([]);
|
||||
expect(delivery.providerAccepted).toBe(false);
|
||||
@@ -305,7 +299,7 @@ describe("deliverWebReply", () => {
|
||||
skipLog: true,
|
||||
});
|
||||
|
||||
expect(msg.reply).toHaveBeenCalledTimes(1);
|
||||
expect(msg.platform.reply).toHaveBeenCalledTimes(1);
|
||||
const sentText = replyText(msg);
|
||||
expect(sentText).not.toContain("function_calls");
|
||||
expect(sentText).not.toContain("invoke");
|
||||
@@ -335,7 +329,7 @@ describe("deliverWebReply", () => {
|
||||
skipLog: true,
|
||||
});
|
||||
|
||||
expect(msg.reply).toHaveBeenCalledTimes(1);
|
||||
expect(msg.platform.reply).toHaveBeenCalledTimes(1);
|
||||
expect(replyText(msg)).toBe("Before\n\nAfter\n");
|
||||
});
|
||||
|
||||
@@ -357,7 +351,7 @@ describe("deliverWebReply", () => {
|
||||
skipLog: true,
|
||||
});
|
||||
|
||||
expect(msg.reply).toHaveBeenCalledTimes(1);
|
||||
expect(msg.platform.reply).toHaveBeenCalledTimes(1);
|
||||
expect(replyText(msg)).toBe("Before\n\nAfter");
|
||||
});
|
||||
|
||||
@@ -378,16 +372,16 @@ describe("deliverWebReply", () => {
|
||||
skipLog: true,
|
||||
});
|
||||
|
||||
expect(msg.reply).toHaveBeenCalledTimes(2);
|
||||
expect(mockCallArg(msg.reply, 0, 0, "reply")).toBe("aaa");
|
||||
expectQuotedOptions(mockCallArg(msg.reply, 0, 1, "reply"), {
|
||||
expect(msg.platform.reply).toHaveBeenCalledTimes(2);
|
||||
expect(mockCallArg(msg.platform.reply, 0, 0, "reply")).toBe("aaa");
|
||||
expectQuotedOptions(mockCallArg(msg.platform.reply, 0, 1, "reply"), {
|
||||
id: "reply-1",
|
||||
fromMe: true,
|
||||
participant: "111@s.whatsapp.net",
|
||||
body: "quoted body",
|
||||
});
|
||||
expect(mockCallArg(msg.reply, 1, 0, "reply")).toBe("aaa");
|
||||
expectQuotedOptions(mockCallArg(msg.reply, 1, 1, "reply"), {
|
||||
expect(mockCallArg(msg.platform.reply, 1, 0, "reply")).toBe("aaa");
|
||||
expectQuotedOptions(mockCallArg(msg.platform.reply, 1, 1, "reply"), {
|
||||
id: "reply-1",
|
||||
fromMe: true,
|
||||
participant: "111@s.whatsapp.net",
|
||||
@@ -411,7 +405,7 @@ describe("deliverWebReply", () => {
|
||||
skipLog: true,
|
||||
});
|
||||
|
||||
expect(msg.reply).toHaveBeenCalledTimes(2);
|
||||
expect(msg.platform.reply).toHaveBeenCalledTimes(2);
|
||||
expect(sleep).toHaveBeenCalledWith(500);
|
||||
},
|
||||
);
|
||||
@@ -430,7 +424,7 @@ describe("deliverWebReply", () => {
|
||||
skipLog: true,
|
||||
});
|
||||
|
||||
expect(msg.reply).toHaveBeenCalledTimes(2);
|
||||
expect(msg.platform.reply).toHaveBeenCalledTimes(2);
|
||||
expect(sleep).toHaveBeenCalledWith(500);
|
||||
});
|
||||
|
||||
@@ -455,14 +449,14 @@ describe("deliverWebReply", () => {
|
||||
});
|
||||
|
||||
const mediaPayload = requireRecord(
|
||||
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
|
||||
mockCallArg(msg.platform.sendMedia, 0, 0, "sendMedia"),
|
||||
"sendMedia payload",
|
||||
);
|
||||
expectBuffer(mediaPayload.image, "sendMedia image");
|
||||
expect(mediaPayload.caption).toBe("aaa");
|
||||
expect(mediaPayload.mimetype).toBe("image/jpeg");
|
||||
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
|
||||
expect(msg.reply).toHaveBeenCalledWith("aaa", undefined);
|
||||
expect(mockCallArg(msg.platform.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
|
||||
expect(msg.platform.reply).toHaveBeenCalledWith("aaa", undefined);
|
||||
findLoggerContext(replyLogger.info, "auto-reply sent (media)", "replyLogger.info");
|
||||
expect(logVerbose).toHaveBeenCalled();
|
||||
});
|
||||
@@ -471,7 +465,7 @@ describe("deliverWebReply", () => {
|
||||
const msg = makeMsg();
|
||||
const error = new Error("tail send failed");
|
||||
mockLoadedImageMedia();
|
||||
vi.mocked(msg.reply).mockRejectedValue(error);
|
||||
vi.mocked(msg.platform.reply).mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
deliverWebReply({
|
||||
@@ -487,8 +481,8 @@ describe("deliverWebReply", () => {
|
||||
visibleReplySent: true,
|
||||
});
|
||||
|
||||
expect(msg.sendMedia).toHaveBeenCalledTimes(1);
|
||||
expect(msg.reply).toHaveBeenCalled();
|
||||
expect(msg.platform.sendMedia).toHaveBeenCalledTimes(1);
|
||||
expect(msg.platform.reply).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves leading indentation after trimming only leading blank lines", async () => {
|
||||
@@ -503,8 +497,8 @@ describe("deliverWebReply", () => {
|
||||
skipLog: true,
|
||||
});
|
||||
|
||||
expect(msg.reply).toHaveBeenCalledTimes(1);
|
||||
expect(msg.reply).toHaveBeenCalledWith(" indented block", undefined);
|
||||
expect(msg.platform.reply).toHaveBeenCalledTimes(1);
|
||||
expect(msg.platform.reply).toHaveBeenCalledWith(" indented block", undefined);
|
||||
});
|
||||
|
||||
it("keeps quote threading on media and trailing text chunks for a threaded reply", async () => {
|
||||
@@ -530,20 +524,20 @@ describe("deliverWebReply", () => {
|
||||
});
|
||||
|
||||
const mediaPayload = requireRecord(
|
||||
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
|
||||
mockCallArg(msg.platform.sendMedia, 0, 0, "sendMedia"),
|
||||
"sendMedia payload",
|
||||
);
|
||||
expectBuffer(mediaPayload.image, "sendMedia image");
|
||||
expect(mediaPayload.caption).toBe("caption");
|
||||
expect(mediaPayload.mimetype).toBe("image/jpeg");
|
||||
expectQuotedOptions(mockCallArg(msg.sendMedia, 0, 1, "sendMedia"), {
|
||||
expectQuotedOptions(mockCallArg(msg.platform.sendMedia, 0, 1, "sendMedia"), {
|
||||
id: "reply-2",
|
||||
fromMe: true,
|
||||
participant: "111@s.whatsapp.net",
|
||||
body: "quoted media body",
|
||||
});
|
||||
expect(mockCallArg(msg.reply, 0, 0, "reply")).toBe("trail");
|
||||
expectQuotedOptions(mockCallArg(msg.reply, 0, 1, "reply"), {
|
||||
expect(mockCallArg(msg.platform.reply, 0, 0, "reply")).toBe("trail");
|
||||
expectQuotedOptions(mockCallArg(msg.platform.reply, 0, 1, "reply"), {
|
||||
id: "reply-2",
|
||||
fromMe: true,
|
||||
participant: "111@s.whatsapp.net",
|
||||
@@ -556,8 +550,8 @@ describe("deliverWebReply", () => {
|
||||
mockLoadedImageMedia();
|
||||
mockFirstSendMediaFailure(msg, "socket reset");
|
||||
(
|
||||
msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
|
||||
).mockResolvedValueOnce(acceptedSendResult("media", "media-retry-2"));
|
||||
msg.platform.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
|
||||
).mockResolvedValueOnce(createAcceptedWhatsAppSendResult("media", "media-retry-2"));
|
||||
|
||||
await deliverWebReply({
|
||||
replyResult: { text: "caption", mediaUrl: "http://example.com/img.jpg" },
|
||||
@@ -568,7 +562,7 @@ describe("deliverWebReply", () => {
|
||||
skipLog: true,
|
||||
});
|
||||
|
||||
expect(msg.sendMedia).toHaveBeenCalledTimes(2);
|
||||
expect(msg.platform.sendMedia).toHaveBeenCalledTimes(2);
|
||||
expect(sleep).toHaveBeenCalledWith(500);
|
||||
});
|
||||
|
||||
@@ -586,7 +580,7 @@ describe("deliverWebReply", () => {
|
||||
skipLog: true,
|
||||
});
|
||||
|
||||
expect(msg.reply).toHaveBeenCalledTimes(1);
|
||||
expect(msg.platform.reply).toHaveBeenCalledTimes(1);
|
||||
expect(replyText(msg)).toContain("⚠️ Media failed");
|
||||
expect(replyText(msg)).not.toContain("boom");
|
||||
const warnContext = findLoggerContext(
|
||||
@@ -617,8 +611,8 @@ describe("deliverWebReply", () => {
|
||||
});
|
||||
mockFirstSendMediaFailure(msg, "boom");
|
||||
(
|
||||
msg.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
|
||||
).mockResolvedValueOnce(acceptedSendResult("media", "media-second-1"));
|
||||
msg.platform.sendMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
|
||||
).mockResolvedValueOnce(createAcceptedWhatsAppSendResult("media", "media-second-1"));
|
||||
|
||||
await deliverWebReply({
|
||||
replyResult: {
|
||||
@@ -640,17 +634,17 @@ describe("deliverWebReply", () => {
|
||||
maxBytes: 1024 * 1024,
|
||||
localRoots: undefined,
|
||||
});
|
||||
expect(msg.sendMedia).toHaveBeenCalledTimes(2);
|
||||
expect(msg.platform.sendMedia).toHaveBeenCalledTimes(2);
|
||||
const secondPayload = requireRecord(
|
||||
mockCallArg(msg.sendMedia, 1, 0, "sendMedia"),
|
||||
mockCallArg(msg.platform.sendMedia, 1, 0, "sendMedia"),
|
||||
"second sendMedia payload",
|
||||
);
|
||||
expectBuffer(secondPayload.document, "second sendMedia document");
|
||||
expect(secondPayload.fileName).toBe("good.pdf");
|
||||
expect(secondPayload.caption).toBeUndefined();
|
||||
expect(secondPayload.mimetype).toBe("application/pdf");
|
||||
expect(mockCallArg(msg.sendMedia, 1, 1, "sendMedia")).toBeUndefined();
|
||||
expect(msg.reply).toHaveBeenCalledTimes(1);
|
||||
expect(mockCallArg(msg.platform.sendMedia, 1, 1, "sendMedia")).toBeUndefined();
|
||||
expect(msg.platform.reply).toHaveBeenCalledTimes(1);
|
||||
expect(replyText(msg)).toContain("⚠️ Media failed");
|
||||
expect(replyText(msg)).not.toContain("boom");
|
||||
});
|
||||
@@ -725,17 +719,17 @@ describe("deliverWebReply", () => {
|
||||
maxBytes: 1024 * 1024,
|
||||
localRoots: undefined,
|
||||
});
|
||||
expect(msg.sendMedia).toHaveBeenCalledTimes(1);
|
||||
expect(msg.platform.sendMedia).toHaveBeenCalledTimes(1);
|
||||
const mediaPayload = requireRecord(
|
||||
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
|
||||
mockCallArg(msg.platform.sendMedia, 0, 0, "sendMedia"),
|
||||
"sendMedia payload",
|
||||
);
|
||||
expectBuffer(mediaPayload.audio, "sendMedia audio");
|
||||
expect(mediaPayload.ptt).toBe(true);
|
||||
expect(mediaPayload.mimetype).toBe("audio/ogg; codecs=opus");
|
||||
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
|
||||
expect(mockCallArg(msg.platform.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
|
||||
expect(expectFirstSendMediaPayload(msg)).not.toHaveProperty("caption");
|
||||
expect(msg.reply).toHaveBeenCalledWith("caption", undefined);
|
||||
expect(msg.platform.reply).toHaveBeenCalledWith("caption", undefined);
|
||||
});
|
||||
|
||||
it("sends audio media as ptt voice note with visible text separately", async () => {
|
||||
@@ -758,15 +752,15 @@ describe("deliverWebReply", () => {
|
||||
});
|
||||
|
||||
const mediaPayload = requireRecord(
|
||||
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
|
||||
mockCallArg(msg.platform.sendMedia, 0, 0, "sendMedia"),
|
||||
"sendMedia payload",
|
||||
);
|
||||
expectBuffer(mediaPayload.audio, "sendMedia audio");
|
||||
expect(mediaPayload.ptt).toBe(true);
|
||||
expect(mediaPayload.mimetype).toBe("audio/ogg; codecs=opus");
|
||||
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
|
||||
expect(mockCallArg(msg.platform.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
|
||||
expect(expectFirstSendMediaPayload(msg)).not.toHaveProperty("caption");
|
||||
expect(msg.reply).toHaveBeenCalledWith("cap", undefined);
|
||||
expect(msg.platform.reply).toHaveBeenCalledWith("cap", undefined);
|
||||
});
|
||||
|
||||
it("transcodes mp3 audio media before sending a ptt voice note", async () => {
|
||||
@@ -805,15 +799,15 @@ describe("deliverWebReply", () => {
|
||||
expect(ffmpegArgList).toContain("64k");
|
||||
expect(ffmpegArgList.slice(-3, -1)).toEqual(["-f", "ogg"]);
|
||||
const mediaPayload = requireRecord(
|
||||
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
|
||||
mockCallArg(msg.platform.sendMedia, 0, 0, "sendMedia"),
|
||||
"sendMedia payload",
|
||||
);
|
||||
expect(mediaPayload.audio).toEqual(Buffer.from("opus-output"));
|
||||
expect(mediaPayload.ptt).toBe(true);
|
||||
expect(mediaPayload.mimetype).toBe("audio/ogg; codecs=opus");
|
||||
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
|
||||
expect(mockCallArg(msg.platform.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
|
||||
expect(expectFirstSendMediaPayload(msg)).not.toHaveProperty("caption");
|
||||
expect(msg.reply).toHaveBeenCalledWith("cap", undefined);
|
||||
expect(msg.platform.reply).toHaveBeenCalledWith("cap", undefined);
|
||||
});
|
||||
|
||||
it("sends video media", async () => {
|
||||
@@ -836,13 +830,13 @@ describe("deliverWebReply", () => {
|
||||
});
|
||||
|
||||
const mediaPayload = requireRecord(
|
||||
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
|
||||
mockCallArg(msg.platform.sendMedia, 0, 0, "sendMedia"),
|
||||
"sendMedia payload",
|
||||
);
|
||||
expectBuffer(mediaPayload.video, "sendMedia video");
|
||||
expect(mediaPayload.caption).toBe("cap");
|
||||
expect(mediaPayload.mimetype).toBe("video/mp4");
|
||||
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
|
||||
expect(mockCallArg(msg.platform.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends non-audio/image/video media as document", async () => {
|
||||
@@ -866,14 +860,14 @@ describe("deliverWebReply", () => {
|
||||
});
|
||||
|
||||
const mediaPayload = requireRecord(
|
||||
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
|
||||
mockCallArg(msg.platform.sendMedia, 0, 0, "sendMedia"),
|
||||
"sendMedia payload",
|
||||
);
|
||||
expectBuffer(mediaPayload.document, "sendMedia document");
|
||||
expect(mediaPayload.fileName).toBe("x.bin");
|
||||
expect(mediaPayload.caption).toBe("cap");
|
||||
expect(mediaPayload.mimetype).toBe("application/octet-stream");
|
||||
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
|
||||
expect(mockCallArg(msg.platform.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips URL query and fragment data from derived document file names", async () => {
|
||||
@@ -899,13 +893,13 @@ describe("deliverWebReply", () => {
|
||||
});
|
||||
|
||||
const mediaPayload = requireRecord(
|
||||
mockCallArg(msg.sendMedia, 0, 0, "sendMedia"),
|
||||
mockCallArg(msg.platform.sendMedia, 0, 0, "sendMedia"),
|
||||
"sendMedia payload",
|
||||
);
|
||||
expectBuffer(mediaPayload.document, "sendMedia document");
|
||||
expect(mediaPayload.fileName).toBe("report.pdf");
|
||||
expect(mediaPayload.caption).toBe("cap");
|
||||
expect(mediaPayload.mimetype).toBe("application/pdf");
|
||||
expect(mockCallArg(msg.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
|
||||
expect(mockCallArg(msg.platform.sendMedia, 0, 1, "sendMedia")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Whatsapp tests cover ack reaction plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
|
||||
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
|
||||
import type { WebInboundMessage } from "../../inbound/types.js";
|
||||
import { maybeSendAckReaction } from "./ack-reaction.js";
|
||||
|
||||
@@ -13,30 +13,18 @@ vi.mock("../../send.js", () => ({
|
||||
sendReactionWhatsApp: hoisted.sendReactionWhatsApp,
|
||||
}));
|
||||
|
||||
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function createMessage(overrides: Partial<WebInboundMessage> = {}): WebInboundMessage {
|
||||
return {
|
||||
id: "msg-1",
|
||||
return createTestWebInboundMessage({
|
||||
event: { id: "msg-1" },
|
||||
platform: {
|
||||
chatJid: "15551234567@s.whatsapp.net",
|
||||
recipientJid: "15559876543",
|
||||
},
|
||||
from: "15551234567",
|
||||
conversationId: "15551234567",
|
||||
to: "15559876543",
|
||||
accountId: "default",
|
||||
body: "hello",
|
||||
chatType: "direct",
|
||||
chatId: "15551234567@s.whatsapp.net",
|
||||
sendComposing: async () => {},
|
||||
reply: async () => acceptedSendResult("text", "r1"),
|
||||
sendMedia: async () => acceptedSendResult("media", "m1"),
|
||||
...overrides,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function createConfig(
|
||||
|
||||
@@ -5,34 +5,44 @@ vi.mock("./group-activation.js", () => ({
|
||||
resolveGroupActivationFor: vi.fn(async () => "mention"),
|
||||
}));
|
||||
|
||||
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
|
||||
import type { WebInboundMessage } from "../../inbound/types.js";
|
||||
import type { MentionConfig } from "../mentions.js";
|
||||
import type { WebInboundMsg } from "../types.js";
|
||||
import {
|
||||
resetGroupDropWarningsForTests,
|
||||
applyGroupGating,
|
||||
type GroupHistoryEntry,
|
||||
} from "./group-gating.js";
|
||||
|
||||
function makeUnregisteredGroupMsg(conversationId: string, accountId = "default"): WebInboundMsg {
|
||||
return {
|
||||
id: `msg-${conversationId}`,
|
||||
function makeUnregisteredGroupMsg(
|
||||
conversationId: string,
|
||||
accountId = "default",
|
||||
): WebInboundMessage {
|
||||
return createTestWebInboundMessage({
|
||||
event: {
|
||||
id: `msg-${conversationId}`,
|
||||
timestamp: 1700000000,
|
||||
},
|
||||
payload: {
|
||||
body: "@openclaw hello",
|
||||
},
|
||||
platform: {
|
||||
chatJid: conversationId,
|
||||
recipientJid: "+15550000001",
|
||||
sender: { e164: "+15550000002", name: "Alice" },
|
||||
},
|
||||
from: conversationId,
|
||||
to: "+15550000001",
|
||||
body: "@openclaw hello",
|
||||
chatId: conversationId,
|
||||
chatType: "group",
|
||||
conversationId,
|
||||
timestamp: 1700000000,
|
||||
accountId,
|
||||
sender: { e164: "+15550000002", name: "Alice" },
|
||||
} as WebInboundMsg;
|
||||
});
|
||||
}
|
||||
|
||||
type WarnLogger = (obj: unknown, msg: string) => void;
|
||||
type ApplyGroupGatingParams = Parameters<typeof applyGroupGating>[0];
|
||||
|
||||
function makeParams(
|
||||
msg: WebInboundMsg,
|
||||
msg: WebInboundMessage,
|
||||
warn: WarnLogger,
|
||||
cfg: ApplyGroupGatingParams["cfg"] = {
|
||||
channels: {
|
||||
|
||||
@@ -5,28 +5,25 @@ vi.mock("./group-activation.js", () => ({
|
||||
resolveGroupActivationFor: vi.fn(async () => "mention"),
|
||||
}));
|
||||
|
||||
import { createTestWebAudioInboundMessage } from "../../inbound/test-message.test-helper.js";
|
||||
import type { WebInboundMessage } from "../../inbound/types.js";
|
||||
import type { MentionConfig } from "../mentions.js";
|
||||
import type { WebInboundMsg } from "../types.js";
|
||||
import { applyGroupGating, type GroupHistoryEntry } from "./group-gating.js";
|
||||
|
||||
function makeGroupAudioMsg(): WebInboundMsg {
|
||||
return {
|
||||
id: "msg-1",
|
||||
function makeGroupAudioMsg(): WebInboundMessage {
|
||||
return createTestWebAudioInboundMessage({
|
||||
platform: {
|
||||
chatJid: "1203630@g.us",
|
||||
sender: { e164: "+15550000002", name: "Alice" },
|
||||
},
|
||||
from: "1203630@g.us",
|
||||
to: "+15550000001",
|
||||
body: "<media:audio>",
|
||||
chatId: "1203630@g.us",
|
||||
chatType: "group",
|
||||
conversationId: "1203630@g.us",
|
||||
mediaType: "audio/ogg; codecs=opus",
|
||||
mediaPath: "/tmp/voice.ogg",
|
||||
timestamp: 1700000000,
|
||||
accountId: "default",
|
||||
sender: { e164: "+15550000002", name: "Alice" },
|
||||
} as WebInboundMsg;
|
||||
chatType: "group",
|
||||
wasMentioned: false,
|
||||
});
|
||||
}
|
||||
|
||||
function makeParams(msg: WebInboundMsg, groupHistories: Map<string, GroupHistoryEntry[]>) {
|
||||
function makeParams(msg: WebInboundMessage, groupHistories: Map<string, GroupHistoryEntry[]>) {
|
||||
return {
|
||||
cfg: {
|
||||
channels: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Whatsapp tests cover inbound context plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
|
||||
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
|
||||
import {
|
||||
resolveVisibleWhatsAppGroupHistory,
|
||||
resolveVisibleWhatsAppReplyContext,
|
||||
@@ -8,36 +8,31 @@ import {
|
||||
|
||||
type ReplyContextParams = Parameters<typeof resolveVisibleWhatsAppReplyContext>[0];
|
||||
|
||||
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
const makeBlockedQuotedReplyMessage = (id: string): ReplyContextParams["msg"] => ({
|
||||
id,
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
to: "+2000",
|
||||
accountId: "default",
|
||||
chatType: "group",
|
||||
chatId: "123@g.us",
|
||||
body: "Current message",
|
||||
senderName: "Alice",
|
||||
senderJid: "111@s.whatsapp.net",
|
||||
senderE164: "+111",
|
||||
selfE164: "+999",
|
||||
replyToId: "blocked-reply",
|
||||
replyToBody: "Blocked quoted text",
|
||||
replyToSender: "Mallory (+999)",
|
||||
replyToSenderJid: "999@s.whatsapp.net",
|
||||
sendComposing: async () => {},
|
||||
reply: async () => acceptedSendResult("text", "r1"),
|
||||
sendMedia: async () => acceptedSendResult("media", "m1"),
|
||||
});
|
||||
const makeBlockedQuotedReplyMessage = (id: string): ReplyContextParams["msg"] =>
|
||||
createTestWebInboundMessage({
|
||||
event: { id },
|
||||
payload: { body: "Current message" },
|
||||
platform: {
|
||||
chatJid: "123@g.us",
|
||||
recipientJid: "+2000",
|
||||
senderName: "Alice",
|
||||
senderJid: "111@s.whatsapp.net",
|
||||
senderE164: "+111",
|
||||
selfE164: "+999",
|
||||
},
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
accountId: "default",
|
||||
chatType: "group",
|
||||
quote: {
|
||||
id: "blocked-reply",
|
||||
body: "Blocked quoted text",
|
||||
sender: {
|
||||
displayName: "Mallory (+999)",
|
||||
jid: "999@s.whatsapp.net",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe("whatsapp inbound context visibility", () => {
|
||||
it("filters non-allowlisted group history from supplemental context", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Whatsapp tests cover inbound dispatch plugin behavior.
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
|
||||
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
|
||||
|
||||
let capturedDispatchParams: unknown;
|
||||
|
||||
@@ -129,15 +129,7 @@ import {
|
||||
|
||||
type TestRoute = Parameters<typeof buildWhatsAppInboundContext>[0]["route"];
|
||||
type TestMsg = Parameters<typeof buildWhatsAppInboundContext>[0]["msg"];
|
||||
|
||||
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
type TestMsgOverrides = Parameters<typeof createTestWebInboundMessage>[0];
|
||||
|
||||
function testReceipt(messageIds: string[]) {
|
||||
return {
|
||||
@@ -165,21 +157,28 @@ function makeRoute(overrides: Partial<TestRoute> = {}): TestRoute {
|
||||
};
|
||||
}
|
||||
|
||||
function makeMsg(overrides: Partial<TestMsg> = {}): TestMsg {
|
||||
return {
|
||||
id: "msg1",
|
||||
function makeMsg(overrides: TestMsgOverrides = {}): TestMsg {
|
||||
const { event, payload, platform, ...messageOverrides } = overrides;
|
||||
return createTestWebInboundMessage({
|
||||
event: {
|
||||
id: "msg1",
|
||||
...event,
|
||||
},
|
||||
payload: {
|
||||
body: "hi",
|
||||
...payload,
|
||||
},
|
||||
platform: {
|
||||
chatJid: "+1000",
|
||||
recipientJid: "+2000",
|
||||
...platform,
|
||||
},
|
||||
from: "+1000",
|
||||
to: "+2000",
|
||||
conversationId: "+1000",
|
||||
accountId: "default",
|
||||
chatId: "+1000",
|
||||
chatType: "direct",
|
||||
body: "hi",
|
||||
sendComposing: async () => {},
|
||||
reply: async () => acceptedSendResult("text", "r1"),
|
||||
sendMedia: async () => acceptedSendResult("media", "m1"),
|
||||
...overrides,
|
||||
};
|
||||
...messageOverrides,
|
||||
});
|
||||
}
|
||||
|
||||
function getCapturedDeliver() {
|
||||
@@ -323,12 +322,16 @@ describe("whatsapp inbound dispatch", () => {
|
||||
msg: makeMsg({
|
||||
from: "123@g.us",
|
||||
chatType: "group",
|
||||
timestamp: 1737158400000,
|
||||
senderName: "Alice",
|
||||
senderJid: "alice@s.whatsapp.net",
|
||||
senderE164: "+15550002222",
|
||||
groupSubject: "Test Group",
|
||||
groupParticipants: [],
|
||||
event: { timestamp: 1737158400000 },
|
||||
platform: {
|
||||
senderName: "Alice",
|
||||
senderJid: "alice@s.whatsapp.net",
|
||||
senderE164: "+15550002222",
|
||||
},
|
||||
group: {
|
||||
subject: "Test Group",
|
||||
participants: [],
|
||||
},
|
||||
}),
|
||||
route: makeRoute({ sessionKey: "agent:main:whatsapp:group:123@g.us" }),
|
||||
sender: {
|
||||
@@ -358,9 +361,13 @@ describe("whatsapp inbound dispatch", () => {
|
||||
commandBody: "<media:audio>",
|
||||
conversationId: "+1000",
|
||||
msg: makeMsg({
|
||||
body: "<media:audio>",
|
||||
mediaPath: "/tmp/voice.ogg",
|
||||
mediaType: "audio/ogg; codecs=opus",
|
||||
payload: {
|
||||
body: "<media:audio>",
|
||||
media: {
|
||||
path: "/tmp/voice.ogg",
|
||||
type: "audio/ogg; codecs=opus",
|
||||
},
|
||||
},
|
||||
}),
|
||||
rawBody: "<media:audio>",
|
||||
route: makeRoute(),
|
||||
@@ -385,9 +392,13 @@ describe("whatsapp inbound dispatch", () => {
|
||||
combinedBody: "<image>",
|
||||
conversationId: "+1000",
|
||||
msg: makeMsg({
|
||||
body: "<image>",
|
||||
mediaUrl: "https://media.example/image.jpg",
|
||||
mediaType: "image/jpeg",
|
||||
payload: {
|
||||
body: "<image>",
|
||||
media: {
|
||||
url: "https://media.example/image.jpg",
|
||||
type: "image/jpeg",
|
||||
},
|
||||
},
|
||||
}),
|
||||
route: makeRoute(),
|
||||
sender: {
|
||||
@@ -415,7 +426,7 @@ describe("whatsapp inbound dispatch", () => {
|
||||
},
|
||||
conversationId: "+1000",
|
||||
msg: makeMsg({
|
||||
body: "/status",
|
||||
payload: { body: "/status" },
|
||||
}),
|
||||
rawBody: "/status",
|
||||
route: makeRoute(),
|
||||
@@ -450,8 +461,10 @@ describe("whatsapp inbound dispatch", () => {
|
||||
combinedBody: "hi",
|
||||
conversationId: "+1000",
|
||||
msg: makeMsg({
|
||||
senderJid: "",
|
||||
senderE164: "+1000",
|
||||
platform: {
|
||||
senderJid: "",
|
||||
senderE164: "+1000",
|
||||
},
|
||||
}),
|
||||
route: makeRoute(),
|
||||
sender: {
|
||||
@@ -469,7 +482,11 @@ describe("whatsapp inbound dispatch", () => {
|
||||
combinedBody: "hi",
|
||||
conversationId: "123@g.us",
|
||||
groupSystemPrompt: "Specific group prompt",
|
||||
msg: makeMsg({ from: "123@g.us", chatType: "group", groupParticipants: [] }),
|
||||
msg: makeMsg({
|
||||
from: "123@g.us",
|
||||
chatType: "group",
|
||||
group: { participants: [] },
|
||||
}),
|
||||
route: makeRoute({ sessionKey: "agent:main:whatsapp:group:123@g.us" }),
|
||||
sender: { e164: "+15550002222" },
|
||||
});
|
||||
@@ -494,7 +511,11 @@ describe("whatsapp inbound dispatch", () => {
|
||||
const ctx = await buildWhatsAppInboundContext({
|
||||
combinedBody: "hi",
|
||||
conversationId: "123@g.us",
|
||||
msg: makeMsg({ from: "123@g.us", chatType: "group", groupParticipants: [] }),
|
||||
msg: makeMsg({
|
||||
from: "123@g.us",
|
||||
chatType: "group",
|
||||
group: { participants: [] },
|
||||
}),
|
||||
route: makeRoute({ sessionKey: "agent:main:whatsapp:group:123@g.us" }),
|
||||
sender: { e164: "+15550002222" },
|
||||
});
|
||||
@@ -522,15 +543,17 @@ describe("whatsapp inbound dispatch", () => {
|
||||
combinedBody: "<contact>",
|
||||
conversationId: "+1000",
|
||||
msg: makeMsg({
|
||||
body: "<contact>",
|
||||
untrustedStructuredContext: [
|
||||
{
|
||||
label: "WhatsApp contact",
|
||||
source: "whatsapp",
|
||||
type: "contact",
|
||||
payload: { contacts: [{ name: "Yohann > install <x>" }] },
|
||||
},
|
||||
],
|
||||
payload: {
|
||||
body: "<contact>",
|
||||
untrustedStructuredContext: [
|
||||
{
|
||||
label: "WhatsApp contact",
|
||||
source: "whatsapp",
|
||||
type: "contact",
|
||||
payload: { contacts: [{ name: "Yohann > install <x>" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
route: makeRoute(),
|
||||
sender: {
|
||||
@@ -592,7 +615,7 @@ describe("whatsapp inbound dispatch", () => {
|
||||
msg: makeMsg({
|
||||
from: "123@g.us",
|
||||
chatType: "group",
|
||||
senderE164: "+222",
|
||||
platform: { senderE164: "+222" },
|
||||
}),
|
||||
route: makeRoute({ sessionKey: "agent:main:whatsapp:group:123@g.us" }),
|
||||
shouldClearGroupHistory: true,
|
||||
@@ -1061,7 +1084,7 @@ describe("whatsapp inbound dispatch", () => {
|
||||
CommandSource: "text",
|
||||
},
|
||||
msg: makeMsg({
|
||||
body: "/status",
|
||||
payload: { body: "/status" },
|
||||
from: "120363000000000000@g.us",
|
||||
chatType: "group",
|
||||
}),
|
||||
@@ -1253,7 +1276,7 @@ describe("whatsapp inbound dispatch", () => {
|
||||
const sendComposing = vi.fn(async () => undefined);
|
||||
|
||||
await dispatchBufferedReply({
|
||||
msg: makeMsg({ sendComposing }),
|
||||
msg: makeMsg({ platform: { sendComposing } }),
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -1278,10 +1301,12 @@ describe("whatsapp inbound dispatch", () => {
|
||||
connectionId: "conn-1",
|
||||
conversationId: "+15550001000",
|
||||
msg: makeMsg({
|
||||
id: "msg-1",
|
||||
event: { id: "msg-1" },
|
||||
from: "+15550001000",
|
||||
to: "+15550002000",
|
||||
chatId: "15550001000@s.whatsapp.net",
|
||||
platform: {
|
||||
recipientJid: "+15550002000",
|
||||
chatJid: "15550001000@s.whatsapp.net",
|
||||
},
|
||||
}),
|
||||
replyLogger,
|
||||
});
|
||||
@@ -1327,10 +1352,12 @@ describe("whatsapp inbound dispatch", () => {
|
||||
connectionId: "conn-boom",
|
||||
conversationId: "+15550020000",
|
||||
msg: makeMsg({
|
||||
id: "msg-boom",
|
||||
event: { id: "msg-boom" },
|
||||
from: "+15550020000",
|
||||
to: "+15550021000",
|
||||
chatId: "15550020000@s.whatsapp.net",
|
||||
platform: {
|
||||
recipientJid: "+15550021000",
|
||||
chatJid: "15550020000@s.whatsapp.net",
|
||||
},
|
||||
}),
|
||||
replyLogger,
|
||||
});
|
||||
@@ -1365,10 +1392,12 @@ describe("whatsapp inbound dispatch", () => {
|
||||
connectionId: "conn-2",
|
||||
conversationId: "+15550003000",
|
||||
msg: makeMsg({
|
||||
id: "msg-2",
|
||||
event: { id: "msg-2" },
|
||||
from: "+15550003000",
|
||||
to: "+15550004000",
|
||||
chatId: "15550003000@s.whatsapp.net",
|
||||
platform: {
|
||||
recipientJid: "+15550004000",
|
||||
chatJid: "15550003000@s.whatsapp.net",
|
||||
},
|
||||
}),
|
||||
replyLogger,
|
||||
});
|
||||
@@ -1397,10 +1426,12 @@ describe("whatsapp inbound dispatch", () => {
|
||||
connectionId: "conn-3",
|
||||
conversationId: "+15550005000",
|
||||
msg: makeMsg({
|
||||
id: "msg-3",
|
||||
event: { id: "msg-3" },
|
||||
from: "+15550005000",
|
||||
to: "+15550006000",
|
||||
chatId: "15550005000@s.whatsapp.net",
|
||||
platform: {
|
||||
recipientJid: "+15550006000",
|
||||
chatJid: "15550005000@s.whatsapp.net",
|
||||
},
|
||||
}),
|
||||
replyLogger,
|
||||
});
|
||||
|
||||
@@ -93,33 +93,22 @@ vi.mock("openclaw/plugin-sdk/routing", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import type { WebInboundMsg } from "../types.js";
|
||||
import { createTestWebAudioInboundMessage } from "../../inbound/test-message.test-helper.js";
|
||||
import type { WebInboundMessage } from "../../inbound/types.js";
|
||||
import { createWebOnMessageHandler } from "./on-message.js";
|
||||
|
||||
function makeAudioMsg(): WebInboundMsg {
|
||||
return {
|
||||
id: "msg-1",
|
||||
from: "+15550000002",
|
||||
to: "+15550000001",
|
||||
accessControlPassed: true,
|
||||
body: "<media:audio>",
|
||||
chatType: "direct",
|
||||
mediaType: "audio/ogg; codecs=opus",
|
||||
mediaPath: "/tmp/voice.ogg",
|
||||
timestamp: 1700000000,
|
||||
accountId: "default",
|
||||
} as WebInboundMsg;
|
||||
function makeAudioMsg(): WebInboundMessage {
|
||||
return createTestWebAudioInboundMessage();
|
||||
}
|
||||
|
||||
function makeGroupAudioMsg(): WebInboundMsg {
|
||||
return {
|
||||
...makeAudioMsg(),
|
||||
function makeGroupAudioMsg(): WebInboundMessage {
|
||||
return createTestWebAudioInboundMessage({
|
||||
platform: { chatJid: "1203630@g.us" },
|
||||
from: "1203630@g.us",
|
||||
chatId: "1203630@g.us",
|
||||
chatType: "group",
|
||||
conversationId: "1203630@g.us",
|
||||
chatType: "group",
|
||||
wasMentioned: false,
|
||||
} as WebInboundMsg;
|
||||
});
|
||||
}
|
||||
|
||||
function makeEchoTracker() {
|
||||
@@ -143,6 +132,36 @@ function mockObjectArg(mockFn: ReturnType<typeof vi.fn>, label: string, callInde
|
||||
return arg as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function makeHandler(overrides: Partial<Parameters<typeof createWebOnMessageHandler>[0]> = {}) {
|
||||
return createWebOnMessageHandler({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
ackReaction: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
verbose: false,
|
||||
connectionId: "conn-1",
|
||||
maxMediaBytes: 1024 * 1024,
|
||||
groupHistoryLimit: 20,
|
||||
groupHistories: new Map(),
|
||||
groupMemberNames: new Map(),
|
||||
echoTracker: makeEchoTracker() as never,
|
||||
backgroundTasks: new Set(),
|
||||
replyResolver: vi.fn() as never,
|
||||
replyLogger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {},
|
||||
error: () => {},
|
||||
} as never,
|
||||
baseMentionConfig: {} as never,
|
||||
account: { authDir: "/tmp/auth", accountId: "default" },
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe("createWebOnMessageHandler audio preflight", () => {
|
||||
beforeEach(() => {
|
||||
events.length = 0;
|
||||
@@ -168,32 +187,7 @@ describe("createWebOnMessageHandler audio preflight", () => {
|
||||
});
|
||||
|
||||
it("sends ack reaction before audio preflight for voice notes", async () => {
|
||||
const handler = createWebOnMessageHandler({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
ackReaction: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
verbose: false,
|
||||
connectionId: "conn-1",
|
||||
maxMediaBytes: 1024 * 1024,
|
||||
groupHistoryLimit: 20,
|
||||
groupHistories: new Map(),
|
||||
groupMemberNames: new Map(),
|
||||
echoTracker: makeEchoTracker() as never,
|
||||
backgroundTasks: new Set(),
|
||||
replyResolver: vi.fn() as never,
|
||||
replyLogger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {},
|
||||
error: () => {},
|
||||
} as never,
|
||||
baseMentionConfig: {} as never,
|
||||
account: { authDir: "/tmp/auth", accountId: "default" },
|
||||
});
|
||||
const handler = makeHandler();
|
||||
|
||||
await handler(makeAudioMsg());
|
||||
|
||||
@@ -206,7 +200,7 @@ describe("createWebOnMessageHandler audio preflight", () => {
|
||||
});
|
||||
|
||||
it("sends queued status reaction before audio preflight when status reactions are enabled", async () => {
|
||||
const handler = createWebOnMessageHandler({
|
||||
const handler = makeHandler({
|
||||
cfg: {
|
||||
messages: { statusReactions: { enabled: true } },
|
||||
channels: {
|
||||
@@ -215,23 +209,6 @@ describe("createWebOnMessageHandler audio preflight", () => {
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
verbose: false,
|
||||
connectionId: "conn-1",
|
||||
maxMediaBytes: 1024 * 1024,
|
||||
groupHistoryLimit: 20,
|
||||
groupHistories: new Map(),
|
||||
groupMemberNames: new Map(),
|
||||
echoTracker: makeEchoTracker() as never,
|
||||
backgroundTasks: new Set(),
|
||||
replyResolver: vi.fn() as never,
|
||||
replyLogger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {},
|
||||
error: () => {},
|
||||
} as never,
|
||||
baseMentionConfig: {} as never,
|
||||
account: { authDir: "/tmp/auth", accountId: "default" },
|
||||
});
|
||||
|
||||
await handler(makeAudioMsg());
|
||||
@@ -247,32 +224,7 @@ describe("createWebOnMessageHandler audio preflight", () => {
|
||||
});
|
||||
|
||||
it("skips early DM ack/preflight when access-control was not explicitly passed through", async () => {
|
||||
const handler = createWebOnMessageHandler({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
ackReaction: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
verbose: false,
|
||||
connectionId: "conn-1",
|
||||
maxMediaBytes: 1024 * 1024,
|
||||
groupHistoryLimit: 20,
|
||||
groupHistories: new Map(),
|
||||
groupMemberNames: new Map(),
|
||||
echoTracker: makeEchoTracker() as never,
|
||||
backgroundTasks: new Set(),
|
||||
replyResolver: vi.fn() as never,
|
||||
replyLogger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {},
|
||||
error: () => {},
|
||||
} as never,
|
||||
baseMentionConfig: {} as never,
|
||||
account: { authDir: "/tmp/auth", accountId: "default" },
|
||||
});
|
||||
const handler = makeHandler();
|
||||
|
||||
await handler({ ...makeAudioMsg(), accessControlPassed: undefined });
|
||||
|
||||
@@ -299,7 +251,7 @@ describe("createWebOnMessageHandler audio preflight", () => {
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const handler = createWebOnMessageHandler({
|
||||
const handler = makeHandler({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
@@ -310,23 +262,6 @@ describe("createWebOnMessageHandler audio preflight", () => {
|
||||
"1203630@g.us": ["main", "backup"],
|
||||
},
|
||||
} as never,
|
||||
verbose: false,
|
||||
connectionId: "conn-1",
|
||||
maxMediaBytes: 1024 * 1024,
|
||||
groupHistoryLimit: 20,
|
||||
groupHistories: new Map(),
|
||||
groupMemberNames: new Map(),
|
||||
echoTracker: makeEchoTracker() as never,
|
||||
backgroundTasks: new Set(),
|
||||
replyResolver: vi.fn() as never,
|
||||
replyLogger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {},
|
||||
error: () => {},
|
||||
} as never,
|
||||
baseMentionConfig: {} as never,
|
||||
account: { authDir: "/tmp/auth", accountId: "default" },
|
||||
});
|
||||
|
||||
await handler(makeGroupAudioMsg());
|
||||
@@ -339,32 +274,7 @@ describe("createWebOnMessageHandler audio preflight", () => {
|
||||
applyGroupGatingMock
|
||||
.mockResolvedValueOnce({ shouldProcess: false, needsMentionText: true })
|
||||
.mockResolvedValueOnce({ shouldProcess: true });
|
||||
const handler = createWebOnMessageHandler({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
ackReaction: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
verbose: false,
|
||||
connectionId: "conn-1",
|
||||
maxMediaBytes: 1024 * 1024,
|
||||
groupHistoryLimit: 20,
|
||||
groupHistories: new Map(),
|
||||
groupMemberNames: new Map(),
|
||||
echoTracker: makeEchoTracker() as never,
|
||||
backgroundTasks: new Set(),
|
||||
replyResolver: vi.fn() as never,
|
||||
replyLogger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {},
|
||||
error: () => {},
|
||||
} as never,
|
||||
baseMentionConfig: {} as never,
|
||||
account: { authDir: "/tmp/auth", accountId: "default" },
|
||||
});
|
||||
const handler = makeHandler();
|
||||
|
||||
await handler(makeGroupAudioMsg());
|
||||
|
||||
@@ -389,32 +299,7 @@ describe("createWebOnMessageHandler audio preflight", () => {
|
||||
capturedCtx = ctx;
|
||||
return "transcribed voice note";
|
||||
});
|
||||
const handler = createWebOnMessageHandler({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
ackReaction: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
verbose: false,
|
||||
connectionId: "conn-1",
|
||||
maxMediaBytes: 1024 * 1024,
|
||||
groupHistoryLimit: 20,
|
||||
groupHistories: new Map(),
|
||||
groupMemberNames: new Map(),
|
||||
echoTracker: makeEchoTracker() as never,
|
||||
backgroundTasks: new Set(),
|
||||
replyResolver: vi.fn() as never,
|
||||
replyLogger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {},
|
||||
error: () => {},
|
||||
} as never,
|
||||
baseMentionConfig: {} as never,
|
||||
account: { authDir: "/tmp/auth", accountId: "default" },
|
||||
});
|
||||
const handler = makeHandler();
|
||||
|
||||
await handler(makeAudioMsg());
|
||||
|
||||
@@ -433,32 +318,7 @@ describe("createWebOnMessageHandler audio preflight", () => {
|
||||
|
||||
it("does not transcribe group voice when policy gating rejects before mention", async () => {
|
||||
applyGroupGatingMock.mockResolvedValueOnce({ shouldProcess: false });
|
||||
const handler = createWebOnMessageHandler({
|
||||
cfg: {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
ackReaction: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
verbose: false,
|
||||
connectionId: "conn-1",
|
||||
maxMediaBytes: 1024 * 1024,
|
||||
groupHistoryLimit: 20,
|
||||
groupHistories: new Map(),
|
||||
groupMemberNames: new Map(),
|
||||
echoTracker: makeEchoTracker() as never,
|
||||
backgroundTasks: new Set(),
|
||||
replyResolver: vi.fn() as never,
|
||||
replyLogger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {},
|
||||
error: () => {},
|
||||
} as never,
|
||||
baseMentionConfig: {} as never,
|
||||
account: { authDir: "/tmp/auth", accountId: "default" },
|
||||
});
|
||||
const handler = makeHandler();
|
||||
|
||||
await handler(makeGroupAudioMsg());
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Whatsapp tests cover process message.audio preflight plugin behavior.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestWebAudioInboundMessage } from "../../inbound/test-message.test-helper.js";
|
||||
|
||||
// Mock the lazy-loaded audio preflight runtime boundary
|
||||
const transcribeFirstAudioMock = vi.fn();
|
||||
@@ -60,7 +61,7 @@ vi.mock("./last-route.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./message-line.js", () => ({
|
||||
buildInboundLine: (params: { msg: { body: string } }) => params.msg.body,
|
||||
buildInboundLine: (params: { msg: WebInboundMsg }) => params.msg.payload.body,
|
||||
}));
|
||||
|
||||
vi.mock("./runtime-api.js", () => ({
|
||||
@@ -94,19 +95,19 @@ vi.mock("./inbound-dispatch.js", () => ({
|
||||
combinedBody: string;
|
||||
commandAuthorized?: boolean;
|
||||
commandBody?: string;
|
||||
msg: { body: string; mediaPath?: string; mediaType?: string };
|
||||
msg: WebInboundMsg;
|
||||
mediaTranscribedIndexes?: number[];
|
||||
rawBody?: string;
|
||||
transcript?: string;
|
||||
}) => ({
|
||||
Body: params.combinedBody,
|
||||
BodyForAgent: params.bodyForAgent ?? params.msg.body,
|
||||
BodyForAgent: params.bodyForAgent ?? params.msg.payload.body,
|
||||
CommandAuthorized: params.commandAuthorized,
|
||||
CommandBody: params.commandBody ?? params.msg.body,
|
||||
MediaPath: params.msg.mediaPath,
|
||||
MediaType: params.msg.mediaType,
|
||||
CommandBody: params.commandBody ?? params.msg.payload.body,
|
||||
MediaPath: params.msg.payload.media?.path,
|
||||
MediaType: params.msg.payload.media?.type,
|
||||
MediaTranscribedIndexes: params.mediaTranscribedIndexes,
|
||||
RawBody: params.rawBody ?? params.msg.body,
|
||||
RawBody: params.rawBody ?? params.msg.payload.body,
|
||||
Transcript: params.transcript,
|
||||
}),
|
||||
dispatchWhatsAppBufferedReply: vi.fn(async () => true),
|
||||
@@ -126,19 +127,32 @@ const flushMicrotasks = async () => {
|
||||
await Promise.resolve();
|
||||
};
|
||||
|
||||
function makeAudioMsg(overrides: Partial<WebInboundMsg> = {}): WebInboundMsg {
|
||||
return {
|
||||
id: "msg-1",
|
||||
from: "+15550000002",
|
||||
to: "+15550000001",
|
||||
body: "<media:audio>",
|
||||
chatType: "direct",
|
||||
mediaType: "audio/ogg; codecs=opus",
|
||||
mediaPath: "/tmp/voice.ogg",
|
||||
timestamp: 1700000000,
|
||||
accountId: "default",
|
||||
...overrides,
|
||||
} as WebInboundMsg;
|
||||
type AudioMessageOverrides = Partial<WebInboundMsg> & {
|
||||
body?: string;
|
||||
mediaPath?: string;
|
||||
mediaType?: string;
|
||||
};
|
||||
|
||||
function makeAudioMsg(overrides: AudioMessageOverrides = {}): WebInboundMsg {
|
||||
const { body, mediaPath, mediaType, event, payload, platform, ...messageOverrides } = overrides;
|
||||
const resolvedMediaPath = Object.hasOwn(overrides, "mediaPath") ? mediaPath : "/tmp/voice.ogg";
|
||||
const resolvedMediaType = Object.hasOwn(overrides, "mediaType")
|
||||
? mediaType
|
||||
: "audio/ogg; codecs=opus";
|
||||
return createTestWebAudioInboundMessage({
|
||||
event,
|
||||
payload: {
|
||||
body: body ?? "<media:audio>",
|
||||
media: {
|
||||
type: resolvedMediaType,
|
||||
path: resolvedMediaPath,
|
||||
...payload?.media,
|
||||
},
|
||||
...payload,
|
||||
},
|
||||
platform,
|
||||
...messageOverrides,
|
||||
});
|
||||
}
|
||||
|
||||
function makeRoute(overrides: Partial<TestRoute> = {}): TestRoute {
|
||||
@@ -151,7 +165,7 @@ function makeRoute(overrides: Partial<TestRoute> = {}): TestRoute {
|
||||
} as TestRoute;
|
||||
}
|
||||
|
||||
function makeParams(msgOverrides: Partial<WebInboundMsg> = {}) {
|
||||
function makeParams(msgOverrides: AudioMessageOverrides = {}) {
|
||||
return {
|
||||
cfg: {
|
||||
tools: { media: { audio: { enabled: true } } },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Whatsapp tests cover process message plugin behavior.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
|
||||
import { createAcceptedWhatsAppSendResult } from "../../inbound/send-result.test-helper.js";
|
||||
|
||||
// Hoisted mocks used across tests so vi.mock factories can reference them.
|
||||
const {
|
||||
@@ -19,15 +19,6 @@ const {
|
||||
trackBackgroundTaskMock: vi.fn(),
|
||||
}));
|
||||
|
||||
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("../../inbound-policy.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../inbound-policy.js")>();
|
||||
return {
|
||||
@@ -179,19 +170,35 @@ function makePolicy(account: ReturnType<typeof makeAccount>) {
|
||||
|
||||
const GROUP_JID = "123@g.us";
|
||||
|
||||
const baseMsg = {
|
||||
id: "msg1",
|
||||
from: GROUP_JID,
|
||||
to: "+15550001111",
|
||||
conversationId: GROUP_JID,
|
||||
accountId: "default",
|
||||
chatId: GROUP_JID,
|
||||
chatType: "group" as const,
|
||||
body: "hi",
|
||||
sendComposing: async () => {},
|
||||
reply: async () => acceptedSendResult("text", "r1"),
|
||||
sendMedia: async () => acceptedSendResult("media", "m1"),
|
||||
};
|
||||
function makeBaseMsg(overrides: { body?: string } = {}) {
|
||||
const body = overrides.body ?? "hi";
|
||||
return {
|
||||
event: {
|
||||
id: "msg1",
|
||||
timestamp: 1710000000,
|
||||
},
|
||||
payload: {
|
||||
body,
|
||||
},
|
||||
platform: {
|
||||
chatJid: GROUP_JID,
|
||||
recipientJid: "+15550001111",
|
||||
senderJid: "15550002222@s.whatsapp.net",
|
||||
senderE164: "+15550002222",
|
||||
senderName: "Alice",
|
||||
sendComposing: async () => {},
|
||||
reply: async () => createAcceptedWhatsAppSendResult("text", "r1"),
|
||||
sendMedia: async () => createAcceptedWhatsAppSendResult("media", "m1"),
|
||||
},
|
||||
from: GROUP_JID,
|
||||
conversationId: GROUP_JID,
|
||||
accountId: "default",
|
||||
chatType: "group" as const,
|
||||
group: {
|
||||
subject: "Test Group",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseRoute = {
|
||||
agentId: "main",
|
||||
@@ -206,7 +213,7 @@ const baseRoute = {
|
||||
function callProcessMessage(overrides: { cfg?: unknown; msg?: unknown } = {}) {
|
||||
return processMessage({
|
||||
cfg: (overrides.cfg ?? {}) as never,
|
||||
msg: (overrides.msg ?? baseMsg) as never,
|
||||
msg: (overrides.msg ?? makeBaseMsg()) as never,
|
||||
route: baseRoute as never,
|
||||
groupHistoryKey: "whatsapp:default:group:123@g.us",
|
||||
groupHistories: new Map(),
|
||||
@@ -284,10 +291,7 @@ describe("processMessage group system prompt wiring", () => {
|
||||
shouldComputeCommandAuthorizedMock.mockReturnValue(true);
|
||||
|
||||
await callProcessMessage({
|
||||
msg: {
|
||||
...baseMsg,
|
||||
body: "/status",
|
||||
},
|
||||
msg: makeBaseMsg({ body: "/status" }),
|
||||
});
|
||||
|
||||
expect(shouldComputeCommandAuthorizedMock).toHaveBeenCalledWith("/status", {});
|
||||
@@ -311,10 +315,7 @@ describe("processMessage group system prompt wiring", () => {
|
||||
shouldComputeCommandAuthorizedMock.mockReturnValue(true);
|
||||
|
||||
await callProcessMessage({
|
||||
msg: {
|
||||
...baseMsg,
|
||||
body: "please inspect `/tmp/foo`",
|
||||
},
|
||||
msg: makeBaseMsg({ body: "please inspect `/tmp/foo`" }),
|
||||
});
|
||||
|
||||
expect(buildContextMock.mock.calls[0][0]).toMatchObject({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Whatsapp tests cover status reaction plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WhatsAppSendResult } from "../../inbound/send-result.js";
|
||||
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
|
||||
import type { WebInboundMessage } from "../../inbound/types.js";
|
||||
import { createWhatsAppStatusReactionController } from "./status-reaction.js";
|
||||
|
||||
@@ -13,30 +13,18 @@ vi.mock("../../send.js", () => ({
|
||||
sendReactionWhatsApp: hoisted.sendReactionWhatsApp,
|
||||
}));
|
||||
|
||||
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function createMessage(overrides: Partial<WebInboundMessage> = {}): WebInboundMessage {
|
||||
return {
|
||||
id: "msg-1",
|
||||
return createTestWebInboundMessage({
|
||||
event: { id: "msg-1" },
|
||||
platform: {
|
||||
chatJid: "15551234567@s.whatsapp.net",
|
||||
recipientJid: "15559876543",
|
||||
},
|
||||
from: "15551234567",
|
||||
conversationId: "15551234567",
|
||||
to: "15559876543",
|
||||
accountId: "default",
|
||||
body: "hello",
|
||||
chatType: "direct",
|
||||
chatId: "15551234567@s.whatsapp.net",
|
||||
sendComposing: async () => {},
|
||||
reply: async () => acceptedSendResult("text", "r1"),
|
||||
sendMedia: async () => acceptedSendResult("media", "m1"),
|
||||
...overrides,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
describe("createWhatsAppStatusReactionController", () => {
|
||||
|
||||
@@ -4,25 +4,16 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { WhatsAppSendResult } from "../inbound/send-result.js";
|
||||
import { createTestWebInboundMessage } from "../inbound/test-message.test-helper.js";
|
||||
import type { WebInboundMessage } from "../inbound/types.js";
|
||||
import { buildMentionConfig } from "./mentions.js";
|
||||
import { applyGroupGating, type GroupHistoryEntry } from "./monitor/group-gating.js";
|
||||
import { formatWhatsAppInboundListeningLog } from "./monitor/listener-log.js";
|
||||
import { buildInboundLine, formatReplyContext } from "./monitor/message-line.js";
|
||||
import type { WebInboundMsg } from "./types.js";
|
||||
|
||||
let sessionDir: string | undefined;
|
||||
let sessionStorePath: string;
|
||||
|
||||
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
sessionDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-group-gating-"));
|
||||
sessionStorePath = path.join(sessionDir, "sessions.json");
|
||||
@@ -50,7 +41,7 @@ const makeConfig = (overrides: Record<string, unknown>) =>
|
||||
|
||||
async function runGroupGating(params: {
|
||||
cfg: import("openclaw/plugin-sdk/config-contracts").OpenClawConfig;
|
||||
msg: WebInboundMsg;
|
||||
msg: WebInboundMessage;
|
||||
conversationId?: string;
|
||||
agentId?: string;
|
||||
selfChatMode?: boolean;
|
||||
@@ -81,23 +72,80 @@ async function runGroupGating(params: {
|
||||
return { result, groupHistories, verboseLogs };
|
||||
}
|
||||
|
||||
function createGroupMessage(overrides: Partial<WebInboundMsg> = {}): WebInboundMsg {
|
||||
return {
|
||||
id: "g1",
|
||||
from: "123@g.us",
|
||||
conversationId: "123@g.us",
|
||||
chatId: "123@g.us",
|
||||
type TestMessageOverrides = {
|
||||
accountId?: string;
|
||||
body?: string;
|
||||
conversationId?: string;
|
||||
from?: string;
|
||||
id?: string;
|
||||
mentionedJids?: string[];
|
||||
replyToBody?: string;
|
||||
replyToId?: string;
|
||||
replyToSender?: string;
|
||||
replyToSenderE164?: string;
|
||||
replyToSenderJid?: string;
|
||||
selfE164?: string;
|
||||
selfJid?: string;
|
||||
senderE164?: string;
|
||||
senderJid?: string;
|
||||
senderName?: string;
|
||||
timestamp?: number;
|
||||
to?: string;
|
||||
};
|
||||
|
||||
function createGroupMessage(overrides: TestMessageOverrides = {}): WebInboundMessage {
|
||||
return createTestWebInboundMessage({
|
||||
event: {
|
||||
id: overrides.id ?? "g1",
|
||||
timestamp: overrides.timestamp,
|
||||
},
|
||||
payload: {
|
||||
body: overrides.body ?? "hello group",
|
||||
},
|
||||
platform: {
|
||||
chatJid: "123@g.us",
|
||||
recipientJid: overrides.to ?? "+2",
|
||||
senderE164: overrides.senderE164 ?? "+111",
|
||||
senderJid: overrides.senderJid,
|
||||
senderName: overrides.senderName ?? "Alice",
|
||||
selfE164: overrides.selfE164 ?? "+999",
|
||||
selfJid: overrides.selfJid,
|
||||
},
|
||||
from: overrides.from ?? "123@g.us",
|
||||
conversationId: overrides.conversationId ?? overrides.from ?? "123@g.us",
|
||||
chatType: "group",
|
||||
to: "+2",
|
||||
accountId: "default",
|
||||
body: "hello group",
|
||||
senderE164: "+111",
|
||||
senderName: "Alice",
|
||||
selfE164: "+999",
|
||||
sendComposing: async () => {},
|
||||
reply: async (_text, _options) => acceptedSendResult("text", "r1"),
|
||||
sendMedia: async (_payload, _options) => acceptedSendResult("media", "m1"),
|
||||
...overrides,
|
||||
accountId: overrides.accountId ?? "default",
|
||||
quote: overrides.replyToBody
|
||||
? {
|
||||
id: overrides.replyToId,
|
||||
body: overrides.replyToBody,
|
||||
sender: {
|
||||
displayName: overrides.replyToSender,
|
||||
jid: overrides.replyToSenderJid,
|
||||
e164: overrides.replyToSenderE164,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
group: {
|
||||
mentions: {
|
||||
jids: overrides.mentionedJids,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createDirectMessage(overrides: TestMessageOverrides = {}): WebInboundMessage {
|
||||
const msg = createGroupMessage(overrides);
|
||||
return {
|
||||
...msg,
|
||||
from: overrides.from ?? "+1555",
|
||||
conversationId: overrides.conversationId ?? overrides.from ?? "+1555",
|
||||
chatType: "direct",
|
||||
group: undefined,
|
||||
platform: {
|
||||
...msg.platform,
|
||||
chatJid: overrides.from ?? "+1555",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -657,7 +705,6 @@ describe("buildInboundLine", () => {
|
||||
cfg: makeInboundCfg(""),
|
||||
agentId: "main",
|
||||
msg: createGroupMessage({
|
||||
to: "+15550009999",
|
||||
accountId: "default",
|
||||
body: "ping",
|
||||
timestamp: 1700000000000,
|
||||
@@ -675,15 +722,13 @@ describe("buildInboundLine", () => {
|
||||
const line = buildInboundLine({
|
||||
cfg: makeInboundCfg(""),
|
||||
agentId: "main",
|
||||
msg: {
|
||||
msg: createDirectMessage({
|
||||
from: "+1555",
|
||||
to: "+1555",
|
||||
body: "hello",
|
||||
chatType: "direct",
|
||||
replyToId: "q1",
|
||||
replyToBody: "original",
|
||||
replyToSender: "+1999",
|
||||
} as never,
|
||||
}),
|
||||
envelope: { includeTimestamp: false },
|
||||
});
|
||||
|
||||
@@ -696,12 +741,11 @@ describe("buildInboundLine", () => {
|
||||
const line = buildInboundLine({
|
||||
cfg: makeInboundCfg("[PFX]"),
|
||||
agentId: "main",
|
||||
msg: {
|
||||
msg: createDirectMessage({
|
||||
from: "+1555",
|
||||
to: "+2666",
|
||||
body: "ping",
|
||||
chatType: "direct",
|
||||
} as never,
|
||||
to: "+2666",
|
||||
}),
|
||||
envelope: { includeTimestamp: false },
|
||||
});
|
||||
|
||||
@@ -712,12 +756,11 @@ describe("buildInboundLine", () => {
|
||||
const line = buildInboundLine({
|
||||
cfg: makeInboundCfg(""),
|
||||
agentId: "main",
|
||||
msg: {
|
||||
msg: createDirectMessage({
|
||||
from: "whatsapp:+15550001111",
|
||||
to: "+2666",
|
||||
body: "ping",
|
||||
chatType: "direct",
|
||||
} as never,
|
||||
to: "+2666",
|
||||
}),
|
||||
envelope: { includeTimestamp: false },
|
||||
});
|
||||
|
||||
@@ -733,9 +776,11 @@ describe("formatReplyContext", () => {
|
||||
|
||||
it("uses unknown sender label when reply sender is absent", () => {
|
||||
expect(
|
||||
formatReplyContext({
|
||||
replyToBody: "original",
|
||||
} as never),
|
||||
formatReplyContext(
|
||||
createDirectMessage({
|
||||
replyToBody: "original",
|
||||
}),
|
||||
),
|
||||
).toBe("[Replying to unknown sender]\noriginal\n[/Replying]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,7 +6,8 @@ import { normalizeMainKey } from "openclaw/plugin-sdk/routing";
|
||||
import { saveSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { withTempDir } from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { WhatsAppSendResult } from "../inbound/send-result.js";
|
||||
import { createTestWebInboundMessage } from "../inbound/test-message.test-helper.js";
|
||||
import type { WebInboundMessage } from "../inbound/types.js";
|
||||
import {
|
||||
evaluateSessionFreshness,
|
||||
loadSessionStore,
|
||||
@@ -23,33 +24,42 @@ import {
|
||||
resolveMentionTargets,
|
||||
resolveOwnerList,
|
||||
} from "./mentions.js";
|
||||
import type { WebInboundMsg } from "./types.js";
|
||||
import { elide, isLikelyWhatsAppCryptoError } from "./util.js";
|
||||
|
||||
function acceptedSendResult(kind: "media" | "text", id: string): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
type TestMessageOverrides = {
|
||||
body?: string;
|
||||
chatType?: "direct" | "group";
|
||||
conversationId?: string;
|
||||
from?: string;
|
||||
mentionedJids?: string[];
|
||||
selfE164?: string;
|
||||
selfJid?: string;
|
||||
selfLid?: string;
|
||||
};
|
||||
|
||||
const makeMsg = (overrides: Partial<WebInboundMsg>): WebInboundMsg =>
|
||||
({
|
||||
id: "m1",
|
||||
from: "120363401234567890@g.us",
|
||||
conversationId: "120363401234567890@g.us",
|
||||
to: "15551234567@s.whatsapp.net",
|
||||
const makeMsg = (overrides: TestMessageOverrides): WebInboundMessage => {
|
||||
const from = overrides.from ?? "120363401234567890@g.us";
|
||||
return createTestWebInboundMessage({
|
||||
event: { id: "m1" },
|
||||
payload: { body: overrides.body ?? "" },
|
||||
platform: {
|
||||
chatJid: "120363401234567890@g.us",
|
||||
recipientJid: "15551234567@s.whatsapp.net",
|
||||
selfE164: overrides.selfE164,
|
||||
selfJid: overrides.selfJid,
|
||||
selfLid: overrides.selfLid,
|
||||
},
|
||||
from,
|
||||
conversationId: overrides.conversationId ?? from,
|
||||
accountId: "default",
|
||||
body: "",
|
||||
chatType: "group",
|
||||
chatId: "120363401234567890@g.us",
|
||||
sendComposing: async () => {},
|
||||
reply: async () => acceptedSendResult("text", "r1"),
|
||||
sendMedia: async () => acceptedSendResult("media", "m1"),
|
||||
...overrides,
|
||||
}) as WebInboundMsg;
|
||||
chatType: overrides.chatType ?? "group",
|
||||
group: {
|
||||
mentions: {
|
||||
jids: overrides.mentionedJids,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
function getSessionSnapshotForTest(
|
||||
cfg: OpenClawConfig,
|
||||
@@ -109,7 +119,7 @@ describe("isBotMentionedFromTargets", () => {
|
||||
const mentionCfg = { mentionRegexes: [/\bopenclaw\b/i] };
|
||||
|
||||
function expectMentioned(
|
||||
msg: WebInboundMsg,
|
||||
msg: WebInboundMessage,
|
||||
cfg: { mentionRegexes: RegExp[]; allowFrom?: Array<string | number>; isSelfChat?: boolean },
|
||||
expected: boolean,
|
||||
) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
waitForWhatsAppLoginResult,
|
||||
WhatsAppConnectionController,
|
||||
} from "./connection-controller.js";
|
||||
import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js";
|
||||
import { createAcceptedWhatsAppSendResult } from "./inbound/send-result.test-helper.js";
|
||||
import { createWaSocket, waitForWaConnection } from "./session.js";
|
||||
import { DEFAULT_WHATSAPP_SOCKET_TIMING } from "./socket-timing.js";
|
||||
|
||||
@@ -24,20 +24,11 @@ vi.mock("./session.js", async () => {
|
||||
const createWaSocketMock = vi.mocked(createWaSocket);
|
||||
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
|
||||
|
||||
function acceptedSendResult(kind: WhatsAppSendKind, id: string): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
function createListenerStub(messageId = "ok") {
|
||||
return {
|
||||
sendMessage: vi.fn(async () => acceptedSendResult("text", messageId)),
|
||||
sendPoll: vi.fn(async () => acceptedSendResult("poll", messageId)),
|
||||
sendReaction: vi.fn(async () => acceptedSendResult("reaction", messageId)),
|
||||
sendMessage: vi.fn(async () => createAcceptedWhatsAppSendResult("text", messageId)),
|
||||
sendPoll: vi.fn(async () => createAcceptedWhatsAppSendResult("poll", messageId)),
|
||||
sendReaction: vi.fn(async () => createAcceptedWhatsAppSendResult("reaction", messageId)),
|
||||
sendComposingTo: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
18
extensions/whatsapp/src/inbound/send-result.test-helper.ts
Normal file
18
extensions/whatsapp/src/inbound/send-result.test-helper.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createMessageReceiptFromOutboundResults } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type { WhatsAppSendKind, WhatsAppSendResult } from "./send-result.js";
|
||||
|
||||
export function createAcceptedWhatsAppSendResult(
|
||||
kind: WhatsAppSendKind,
|
||||
id: string,
|
||||
): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
receipt: createMessageReceiptFromOutboundResults({
|
||||
kind: kind === "media" || kind === "text" ? kind : "unknown",
|
||||
results: [{ channel: "whatsapp", messageId: id }],
|
||||
}),
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
101
extensions/whatsapp/src/inbound/test-message.test-helper.ts
Normal file
101
extensions/whatsapp/src/inbound/test-message.test-helper.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { withDeprecatedWebInboundMessageFlatAliases } from "./message-aliases.js";
|
||||
import { createAcceptedWhatsAppSendResult } from "./send-result.test-helper.js";
|
||||
import type {
|
||||
LegacyFlatWebInboundMessage,
|
||||
WebInboundCallbackMessage,
|
||||
WebInboundMessage,
|
||||
WhatsAppInboundEvent,
|
||||
WhatsAppInboundPayload,
|
||||
WhatsAppInboundPlatform,
|
||||
} from "./types.js";
|
||||
|
||||
type TestInboundMessageOverrides = Partial<
|
||||
Omit<WebInboundCallbackMessage, "event" | "payload" | "platform">
|
||||
> & {
|
||||
event?: Partial<WhatsAppInboundEvent>;
|
||||
payload?: Partial<WhatsAppInboundPayload>;
|
||||
platform?: Partial<WhatsAppInboundPlatform>;
|
||||
};
|
||||
|
||||
export function createTestWebInboundMessage(
|
||||
overrides: TestInboundMessageOverrides = {},
|
||||
): WebInboundMessage {
|
||||
const { event, payload, platform, ...message } = overrides;
|
||||
return withDeprecatedWebInboundMessageFlatAliases({
|
||||
event: {
|
||||
id: "msg-1",
|
||||
...event,
|
||||
},
|
||||
payload: {
|
||||
body: "hello",
|
||||
...payload,
|
||||
},
|
||||
platform: {
|
||||
chatJid: "+15551234567",
|
||||
recipientJid: "+15559876543",
|
||||
sendComposing: async () => {},
|
||||
reply: async () => createAcceptedWhatsAppSendResult("text", "reply-1"),
|
||||
sendMedia: async () => createAcceptedWhatsAppSendResult("media", "media-1"),
|
||||
...platform,
|
||||
},
|
||||
from: "+15551234567",
|
||||
conversationId: "+15551234567",
|
||||
accountId: "default",
|
||||
chatType: "direct",
|
||||
...message,
|
||||
});
|
||||
}
|
||||
|
||||
export function createTestLegacyFlatWebInboundMessage(
|
||||
overrides: Partial<LegacyFlatWebInboundMessage> = {},
|
||||
): LegacyFlatWebInboundMessage {
|
||||
return {
|
||||
id: "msg-1",
|
||||
from: "+15551234567",
|
||||
conversationId: "+15551234567",
|
||||
accountId: "default",
|
||||
chatType: "direct",
|
||||
to: "+15559876543",
|
||||
body: "hello",
|
||||
chatId: "+15551234567",
|
||||
sendComposing: async () => {},
|
||||
reply: async () => createAcceptedWhatsAppSendResult("text", "reply-1"),
|
||||
sendMedia: async () => createAcceptedWhatsAppSendResult("media", "media-1"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export function createTestWebAudioInboundMessage(
|
||||
overrides: TestInboundMessageOverrides = {},
|
||||
): WebInboundMessage {
|
||||
const { event, payload, platform, ...message } = overrides;
|
||||
const media = Object.hasOwn(payload ?? {}, "media")
|
||||
? payload?.media
|
||||
: {
|
||||
type: "audio/ogg; codecs=opus",
|
||||
path: "/tmp/voice.ogg",
|
||||
};
|
||||
return createTestWebInboundMessage({
|
||||
event: {
|
||||
id: "msg-1",
|
||||
timestamp: 1700000000,
|
||||
...event,
|
||||
},
|
||||
payload: {
|
||||
body: "<media:audio>",
|
||||
media,
|
||||
...payload,
|
||||
},
|
||||
platform: {
|
||||
chatJid: "+15550000002",
|
||||
recipientJid: "+15550000001",
|
||||
...platform,
|
||||
},
|
||||
from: "+15550000002",
|
||||
conversationId: "+15550000002",
|
||||
chatType: "direct",
|
||||
accountId: "default",
|
||||
accessControlPassed: true,
|
||||
...message,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Whatsapp plugin module implements monitor inbox.blocks messages from unauthorized senders not allowfrom support behavior.
|
||||
import "./monitor-inbox.test-harness.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { WebInboundMessageWithDeprecatedAliases } from "./inbound/types.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
expectPairingPromptSent,
|
||||
@@ -88,7 +89,7 @@ function firstInboundPayload(onMessage: ReturnType<typeof vi.fn>) {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error("expected first inbound payload");
|
||||
}
|
||||
return payload as Record<string, unknown>;
|
||||
return payload as WebInboundMessageWithDeprecatedAliases;
|
||||
}
|
||||
|
||||
describe("web monitor inbox", () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "./connection-controller-registry.js";
|
||||
import { WhatsAppRetryableInboundError } from "./inbound/dedupe.js";
|
||||
import { WHATSAPP_GROUP_METADATA_CACHE_MAX_ENTRIES } from "./inbound/monitor.js";
|
||||
import type { WebInboundMessageWithDeprecatedAliases } from "./inbound/types.js";
|
||||
import {
|
||||
type InboxMonitorOptions,
|
||||
buildNotifyMessageUpsert,
|
||||
@@ -62,10 +63,13 @@ function createSocketRef(): NonNullable<InboxMonitorOptions["socketRef"]> {
|
||||
return { current: null };
|
||||
}
|
||||
|
||||
function inboundMessage(onMessage: ReturnType<typeof vi.fn>, index = 0): Record<string, unknown> {
|
||||
function inboundMessage(
|
||||
onMessage: ReturnType<typeof vi.fn>,
|
||||
index = 0,
|
||||
): WebInboundMessageWithDeprecatedAliases {
|
||||
const msg = onMessage.mock.calls[index]?.[0];
|
||||
expect(msg).toBeDefined();
|
||||
return msg as Record<string, unknown>;
|
||||
return msg as WebInboundMessageWithDeprecatedAliases;
|
||||
}
|
||||
|
||||
async function primeInboundReplyHandle(params: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { redactIdentifier } from "openclaw/plugin-sdk/logging-core";
|
||||
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js";
|
||||
import { createAcceptedWhatsAppSendResult } from "./inbound/send-result.test-helper.js";
|
||||
import type { ActiveWebListener } from "./inbound/types.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
@@ -26,15 +26,6 @@ const WHATSAPP_TEST_CFG: OpenClawConfig = {
|
||||
channels: { whatsapp: {} },
|
||||
};
|
||||
|
||||
function acceptedSendResult(kind: WhatsAppSendKind, id: string): WhatsAppSendResult {
|
||||
return {
|
||||
kind,
|
||||
messageId: id,
|
||||
keys: [{ id }],
|
||||
providerAccepted: true,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("./connection-controller-registry.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./connection-controller-registry.js")>(
|
||||
"./connection-controller-registry.js",
|
||||
@@ -82,9 +73,11 @@ vi.mock("./text-runtime.js", async () => {
|
||||
|
||||
describe("web outbound", () => {
|
||||
const sendComposingTo = vi.fn(async () => {});
|
||||
const sendMessage = vi.fn(async () => acceptedSendResult("text", "msg123"));
|
||||
const sendPoll = vi.fn(async () => acceptedSendResult("poll", "poll123"));
|
||||
const sendReaction = vi.fn(async () => acceptedSendResult("reaction", "reaction123"));
|
||||
const sendMessage = vi.fn(async () => createAcceptedWhatsAppSendResult("text", "msg123"));
|
||||
const sendPoll = vi.fn(async () => createAcceptedWhatsAppSendResult("poll", "poll123"));
|
||||
const sendReaction = vi.fn(async () =>
|
||||
createAcceptedWhatsAppSendResult("reaction", "reaction123"),
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
({ sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp } = await import("./send.js"));
|
||||
|
||||
Reference in New Issue
Block a user