mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-17 04:50:51 +00:00
* 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
271 lines
8.0 KiB
TypeScript
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,
|
|
);
|
|
});
|
|
});
|