Files
openclaw/extensions/whatsapp/src/send.test.ts
scoootscooob 16505718e8 refactor: move WhatsApp channel implementation to extensions/ (#45725)
* refactor: move WhatsApp channel from src/web/ to extensions/whatsapp/

Move all WhatsApp implementation code (77 source/test files + 9 channel
plugin files) from src/web/ and src/channels/plugins/*/whatsapp* to
extensions/whatsapp/src/.

- Leave thin re-export shims at all original locations so cross-cutting
  imports continue to resolve
- Update plugin-sdk/whatsapp.ts to only re-export generic framework
  utilities; channel-specific functions imported locally by the extension
- Update vi.mock paths in 15 cross-cutting test files
- Rename outbound.ts -> send.ts to match extension naming conventions
  and avoid false positive in cfg-threading guard test
- Widen tsconfig.plugin-sdk.dts.json rootDir to support shim->extension
  cross-directory references

Part of the core-channels-to-extensions migration (PR 6/10).

* style: format WhatsApp extension files

* fix: correct stale import paths in WhatsApp extension tests

Fix vi.importActual, test mock, and hardcoded source paths that weren't
updated during the file move:
- media.test.ts: vi.importActual path
- onboarding.test.ts: vi.importActual path
- test-helpers.ts: test/mocks/baileys.js path
- monitor-inbox.test-harness.ts: incomplete media/store mock
- login.test.ts: hardcoded source file path
- message-action-runner.media.test.ts: vi.mock/importActual path
2026-03-14 02:44:55 -07:00

271 lines
8.0 KiB
TypeScript

import crypto from "node:crypto";
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { resetLogger, setLoggerOverride } from "../../../src/logging.js";
import { redactIdentifier } from "../../../src/logging/redact-identifier.js";
import { setActiveWebListener } from "./active-listener.js";
const loadWebMediaMock = vi.fn();
vi.mock("./media.js", () => ({
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
}));
import { sendMessageWhatsApp, sendPollWhatsApp, sendReactionWhatsApp } from "./send.js";
describe("web outbound", () => {
const sendComposingTo = vi.fn(async () => {});
const sendMessage = vi.fn(async () => ({ messageId: "msg123" }));
const sendPoll = vi.fn(async () => ({ messageId: "poll123" }));
const sendReaction = vi.fn(async () => {});
beforeEach(() => {
vi.clearAllMocks();
setActiveWebListener({
sendComposingTo,
sendMessage,
sendPoll,
sendReaction,
});
});
afterEach(() => {
resetLogger();
setLoggerOverride(null);
setActiveWebListener(null);
setActiveWebListener("work", null);
});
it("sends message via active listener", async () => {
const result = await sendMessageWhatsApp("+1555", "hi", { verbose: false });
expect(result).toEqual({
messageId: "msg123",
toJid: "1555@s.whatsapp.net",
});
expect(sendComposingTo).toHaveBeenCalledWith("+1555");
expect(sendMessage).toHaveBeenCalledWith("+1555", "hi", undefined, undefined);
});
it("trims leading whitespace before sending text and captions", async () => {
await sendMessageWhatsApp("+1555", "\n \thello", { verbose: false });
expect(sendMessage).toHaveBeenLastCalledWith("+1555", "hello", undefined, undefined);
const buf = Buffer.from("img");
loadWebMediaMock.mockResolvedValueOnce({
buffer: buf,
contentType: "image/jpeg",
kind: "image",
});
await sendMessageWhatsApp("+1555", "\n \tcaption", {
verbose: false,
mediaUrl: "/tmp/pic.jpg",
});
expect(sendMessage).toHaveBeenLastCalledWith("+1555", "caption", buf, "image/jpeg");
});
it("skips whitespace-only text sends without media", async () => {
const result = await sendMessageWhatsApp("+1555", "\n \t", { verbose: false });
expect(result).toEqual({
messageId: "",
toJid: "1555@s.whatsapp.net",
});
expect(sendComposingTo).not.toHaveBeenCalled();
expect(sendMessage).not.toHaveBeenCalled();
});
it("throws a helpful error when no active listener exists", async () => {
setActiveWebListener(null);
await expect(
sendMessageWhatsApp("+1555", "hi", { verbose: false, accountId: "work" }),
).rejects.toThrow(/No active WhatsApp Web listener/);
await expect(
sendMessageWhatsApp("+1555", "hi", { verbose: false, accountId: "work" }),
).rejects.toThrow(/channels login/);
await expect(
sendMessageWhatsApp("+1555", "hi", { verbose: false, accountId: "work" }),
).rejects.toThrow(/account: work/);
});
it("maps audio to PTT with opus mime when ogg", async () => {
const buf = Buffer.from("audio");
loadWebMediaMock.mockResolvedValueOnce({
buffer: buf,
contentType: "audio/ogg",
kind: "audio",
});
await sendMessageWhatsApp("+1555", "voice note", {
verbose: false,
mediaUrl: "/tmp/voice.ogg",
});
expect(sendMessage).toHaveBeenLastCalledWith(
"+1555",
"voice note",
buf,
"audio/ogg; codecs=opus",
);
});
it("maps video with caption", async () => {
const buf = Buffer.from("video");
loadWebMediaMock.mockResolvedValueOnce({
buffer: buf,
contentType: "video/mp4",
kind: "video",
});
await sendMessageWhatsApp("+1555", "clip", {
verbose: false,
mediaUrl: "/tmp/video.mp4",
});
expect(sendMessage).toHaveBeenLastCalledWith("+1555", "clip", buf, "video/mp4");
});
it("marks gif playback for video when requested", async () => {
const buf = Buffer.from("gifvid");
loadWebMediaMock.mockResolvedValueOnce({
buffer: buf,
contentType: "video/mp4",
kind: "video",
});
await sendMessageWhatsApp("+1555", "gif", {
verbose: false,
mediaUrl: "/tmp/anim.mp4",
gifPlayback: true,
});
expect(sendMessage).toHaveBeenLastCalledWith("+1555", "gif", buf, "video/mp4", {
gifPlayback: true,
});
});
it("maps image with caption", async () => {
const buf = Buffer.from("img");
loadWebMediaMock.mockResolvedValueOnce({
buffer: buf,
contentType: "image/jpeg",
kind: "image",
});
await sendMessageWhatsApp("+1555", "pic", {
verbose: false,
mediaUrl: "/tmp/pic.jpg",
});
expect(sendMessage).toHaveBeenLastCalledWith("+1555", "pic", buf, "image/jpeg");
});
it("maps other kinds to document with filename", async () => {
const buf = Buffer.from("pdf");
loadWebMediaMock.mockResolvedValueOnce({
buffer: buf,
contentType: "application/pdf",
kind: "document",
fileName: "file.pdf",
});
await sendMessageWhatsApp("+1555", "doc", {
verbose: false,
mediaUrl: "/tmp/file.pdf",
});
expect(sendMessage).toHaveBeenLastCalledWith("+1555", "doc", buf, "application/pdf", {
fileName: "file.pdf",
});
});
it("uses account-aware WhatsApp media caps for outbound uploads", async () => {
setActiveWebListener("work", {
sendComposingTo,
sendMessage,
sendPoll,
sendReaction,
});
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("img"),
contentType: "image/jpeg",
kind: "image",
});
const cfg = {
channels: {
whatsapp: {
mediaMaxMb: 25,
accounts: {
work: {
mediaMaxMb: 100,
},
},
},
},
} as OpenClawConfig;
await sendMessageWhatsApp("+1555", "pic", {
verbose: false,
accountId: "work",
cfg,
mediaUrl: "/tmp/pic.jpg",
mediaLocalRoots: ["/tmp/workspace"],
});
expect(loadWebMediaMock).toHaveBeenCalledWith("/tmp/pic.jpg", {
maxBytes: 100 * 1024 * 1024,
localRoots: ["/tmp/workspace"],
});
});
it("sends polls via active listener", async () => {
const result = await sendPollWhatsApp(
"+1555",
{ question: "Lunch?", options: ["Pizza", "Sushi"], maxSelections: 2 },
{ verbose: false },
);
expect(result).toEqual({
messageId: "poll123",
toJid: "1555@s.whatsapp.net",
});
expect(sendPoll).toHaveBeenCalledWith("+1555", {
question: "Lunch?",
options: ["Pizza", "Sushi"],
maxSelections: 2,
durationSeconds: undefined,
durationHours: undefined,
});
});
it("redacts recipients and poll text in outbound logs", async () => {
const logPath = path.join(os.tmpdir(), `openclaw-outbound-${crypto.randomUUID()}.log`);
setLoggerOverride({ level: "trace", file: logPath });
await sendPollWhatsApp(
"+1555",
{ question: "Lunch?", options: ["Pizza", "Sushi"], maxSelections: 1 },
{ verbose: false },
);
await vi.waitFor(
() => {
expect(fsSync.existsSync(logPath)).toBe(true);
},
{ timeout: 2_000, interval: 5 },
);
const content = fsSync.readFileSync(logPath, "utf-8");
expect(content).toContain(redactIdentifier("+1555"));
expect(content).toContain(redactIdentifier("1555@s.whatsapp.net"));
expect(content).not.toContain(`"to":"+1555"`);
expect(content).not.toContain(`"jid":"1555@s.whatsapp.net"`);
expect(content).not.toContain("Lunch?");
});
it("sends reactions via active listener", async () => {
await sendReactionWhatsApp("1555@s.whatsapp.net", "msg123", "✅", {
verbose: false,
fromMe: false,
});
expect(sendReaction).toHaveBeenCalledWith(
"1555@s.whatsapp.net",
"msg123",
"✅",
false,
undefined,
);
});
});