test: consolidate WhatsApp monitor suites (#117854)

* test: consolidate WhatsApp monitor suites

* test: preserve WhatsApp max-lines ratchet path
This commit is contained in:
Peter Steinberger
2026-08-02 00:20:17 -07:00
committed by GitHub
parent 98c0d9deca
commit dc38411ca4
24 changed files with 1215 additions and 1597 deletions
@@ -1,4 +1,4 @@
// Whatsapp tests cover auto reply.web auto reply.compresses common formats jpeg cap plugin behavior.
// WhatsApp web auto-reply media delivery behavior.
import fs from "node:fs/promises";
import { createNoisyPngBuffer, createSolidPngBuffer } from "openclaw/plugin-sdk/test-fixtures";
import { beforeAll, describe, expect, it, vi } from "vitest";
@@ -17,7 +17,7 @@ installWebAutoReplyTestHomeHooks();
let monitorWebChannel: typeof import("./auto-reply/monitor.js").monitorWebChannel;
describe("web auto-reply", () => {
describe("web auto-reply media delivery", () => {
installWebAutoReplyUnitTestHooks({ pinDns: true });
type ListenerFactory = NonNullable<Parameters<typeof monitorWebChannel>[1]>;
type WebInboundPlatform = WebInboundCallbackMessage["platform"];
@@ -1,4 +1,4 @@
// Whatsapp tests cover auto reply.web auto reply.last route plugin behavior.
// WhatsApp web auto-reply routing behavior.
import "./test-helpers.js";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -115,7 +115,7 @@ function buildInboundMessage(params: {
});
}
describe("web auto-reply last-route", () => {
describe("web auto-reply routing", () => {
installWebAutoReplyUnitTestHooks();
beforeEach(() => {
@@ -1,110 +0,0 @@
// Whatsapp tests cover ack emoji plugin behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect, it } from "vitest";
import { resolveWhatsAppAckEmoji } from "./ack-emoji.js";
function createConfig(
ackReaction?: NonNullable<
NonNullable<NonNullable<OpenClawConfig["channels"]>["whatsapp"]>["ackReaction"]
>,
): OpenClawConfig {
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "agent", identity: { emoji: "🔥" } }],
},
channels: {
whatsapp: {},
},
} as OpenClawConfig;
if (ackReaction !== undefined) {
cfg.channels!.whatsapp!.ackReaction = ackReaction;
}
return cfg;
}
describe("resolveWhatsAppAckEmoji", () => {
it("keeps missing ackReaction config disabled", () => {
expect(
resolveWhatsAppAckEmoji({
cfg: createConfig(),
agentId: "agent",
ackConfig: undefined,
}),
).toBe("");
});
it("uses the configured WhatsApp emoji when present", () => {
const cfg = createConfig({ emoji: " 👀 ", direct: true, group: "mentions" });
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("👀");
});
it("falls back to the routed agent identity for an empty emoji", () => {
const cfg = createConfig({ emoji: " ", direct: true, group: "mentions" });
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("🔥");
});
it("falls back to the routed agent identity emoji when the ack object has no emoji", () => {
const cfg = createConfig({ direct: true, group: "mentions" });
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("🔥");
});
it("uses normalized agent ids for the identity fallback", () => {
const cfg: OpenClawConfig = {
agents: {
list: [{ id: "Agent", identity: { emoji: "🔥" } }],
},
channels: {
whatsapp: {
ackReaction: { direct: true, group: "mentions" },
},
},
} as OpenClawConfig;
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("🔥");
});
it("uses the default ack emoji when configured without an emoji or agent identity", () => {
const cfg: OpenClawConfig = {
channels: {
whatsapp: {
ackReaction: { direct: true, group: "mentions" },
},
},
} as OpenClawConfig;
expect(
resolveWhatsAppAckEmoji({
cfg,
agentId: "agent",
ackConfig: cfg.channels?.whatsapp?.ackReaction,
}),
).toBe("👀");
});
});
@@ -1,53 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { createEchoTracker } from "./echo.js";
describe("createEchoTracker", () => {
it("keeps verbose previews UTF-16 safe without changing the tracked text", () => {
const logVerbose = vi.fn();
const tracker = createEchoTracker({ logVerbose });
const prefix = "x".repeat(49);
const text = `${prefix}😀tail`;
tracker.rememberText(text, { logVerboseMessage: true });
expect(logVerbose).toHaveBeenCalledExactlyOnceWith(
`Added to echo detection set (size now: 1): ${prefix}...`,
);
expect(tracker.has(text)).toBe(true);
});
it("keeps identical text isolated to its originating conversation", () => {
const tracker = createEchoTracker({});
tracker.rememberText("Done.", { conversationId: "+1000" });
expect(tracker.has("Done.", "+1000")).toBe(true);
expect(tracker.has("Done.", "+3000")).toBe(false);
tracker.forget("Done.", "+1000");
expect(tracker.has("Done.", "+1000")).toBe(false);
});
it("keeps combined-message deduplication independent of conversation-scoped text", () => {
const tracker = createEchoTracker({});
const combinedKey = tracker.buildCombinedKey({
sessionKey: "agent:main:whatsapp:+1000",
combinedBody: "first\nsecond",
});
tracker.rememberText("Done.", {
conversationId: "+1000",
combinedBody: "first\nsecond",
combinedBodySessionKey: "agent:main:whatsapp:+1000",
});
expect(tracker.has(combinedKey)).toBe(true);
expect(tracker.has("Done.", "+1000")).toBe(true);
tracker.forget(combinedKey);
expect(tracker.has(combinedKey)).toBe(false);
expect(tracker.has("Done.", "+1000")).toBe(true);
});
});
@@ -93,78 +93,56 @@ function makeParams(
}
describe("applyGroupGating allowlist drop warning", () => {
it("emits a warn log naming the root groups path for the default account", async () => {
const warn = vi.fn<WarnLogger>();
const msg = makeUnregisteredGroupMsg("root-unregistered@g.us");
const params = makeParams(msg, warn);
const result = await applyGroupGating(params);
expect(result).toEqual({ shouldProcess: false });
expect(warn).toHaveBeenCalledTimes(1);
expect(params.logVerbose).toHaveBeenCalledWith(
'Dropping message from unregistered WhatsApp group root-unregistered@g.us. Add the group JID to channels.whatsapp.groups, or add "*" there to admit all groups. Sender authorization still applies.',
);
const [context, message] = warn.mock.calls[0] ?? [];
expect(context).toMatchObject({
it.each([
{
name: "emits a warn log naming the root groups path for the default account",
conversationId: "root-unregistered@g.us",
accountId: "default",
groupsPath: "channels.whatsapp.groups",
});
expect(message).toContain("root-unregistered@g.us");
expect(message).toContain("channels.whatsapp.groups");
});
it("names the account-scoped groups path for non-default accounts", async () => {
const warn = vi.fn<WarnLogger>();
const msg = makeUnregisteredGroupMsg("work-unregistered@g.us", "work");
await applyGroupGating(makeParams(msg, warn));
expect(warn).toHaveBeenCalledTimes(1);
const [context, message] = warn.mock.calls[0] ?? [];
expect(context).toMatchObject({
cfg: undefined,
verboseMessage:
'Dropping message from unregistered WhatsApp group root-unregistered@g.us. Add the group JID to channels.whatsapp.groups, or add "*" there to admit all groups. Sender authorization still applies.',
},
{
name: "names the account-scoped groups path for non-default accounts",
conversationId: "work-unregistered@g.us",
accountId: "work",
groupsPath: "channels.whatsapp.accounts.work.groups",
});
expect(message).toContain("channels.whatsapp.accounts.work.groups");
});
it("names the root groups path for non-default accounts inheriting root groups", async () => {
const warn = vi.fn<WarnLogger>();
const msg = makeUnregisteredGroupMsg("inherited-unregistered@g.us", "work");
const cfg = {
channels: {
whatsapp: {
groupPolicy: "allowlist",
groups: {
"registered@g.us": {},
},
accounts: {
work: {
groupPolicy: "allowlist",
},
},
},
},
messages: {
groupChat: {
mentionPatterns: ["\\bopenclaw\\b"],
},
},
} as ApplyGroupGatingParams["cfg"];
await applyGroupGating(makeParams(msg, warn, cfg));
expect(warn).toHaveBeenCalledTimes(1);
const [context, message] = warn.mock.calls[0] ?? [];
expect(context).toMatchObject({
cfg: undefined,
verboseMessage: undefined,
},
{
name: "names the root groups path for non-default accounts inheriting root groups",
conversationId: "inherited-unregistered@g.us",
accountId: "work",
groupsPath: "channels.whatsapp.groups",
});
expect(message).toContain("channels.whatsapp.groups");
cfg: {
channels: {
whatsapp: {
groupPolicy: "allowlist",
groups: { "registered@g.us": {} },
accounts: { work: { groupPolicy: "allowlist" } },
},
},
messages: { groupChat: { mentionPatterns: ["\\bopenclaw\\b"] } },
} as ApplyGroupGatingParams["cfg"],
verboseMessage: undefined,
},
])("$name", async ({ conversationId, accountId, groupsPath, cfg, verboseMessage }) => {
const warn = vi.fn<WarnLogger>();
const msg = makeUnregisteredGroupMsg(conversationId, accountId);
const params = makeParams(msg, warn, cfg);
await expect(applyGroupGating(params)).resolves.toEqual({ shouldProcess: false });
expect(warn).toHaveBeenCalledTimes(1);
if (verboseMessage) {
expect(params.logVerbose).toHaveBeenCalledWith(verboseMessage);
}
const [context, message] = warn.mock.calls[0] ?? [];
expect(context).toMatchObject({ conversationId, accountId, groupsPath });
expect(message).toContain(conversationId);
expect(message).toContain(groupsPath);
});
it("warns once but keeps verbose diagnostics per dropped message", async () => {
@@ -1,57 +0,0 @@
// Whatsapp tests cover group members plugin behavior.
import { describe, expect, it } from "vitest";
import { formatGroupMembers, noteGroupMember } from "./group-members.js";
describe("noteGroupMember", () => {
it("normalizes member phone numbers before storing", () => {
const groupMemberNames = new Map<string, Map<string, string>>();
noteGroupMember(groupMemberNames, "g1", "+1 (555) 123-4567", "Alice");
expect(groupMemberNames.get("g1")?.get("+15551234567")).toBe("Alice");
});
it("ignores incomplete member values", () => {
const groupMemberNames = new Map<string, Map<string, string>>();
noteGroupMember(groupMemberNames, "g1", undefined, "Alice");
noteGroupMember(groupMemberNames, "g1", "+15551234567", undefined);
expect(groupMemberNames.get("g1")).toBeUndefined();
});
});
describe("formatGroupMembers", () => {
it("deduplicates participants and appends named roster members", () => {
const roster = new Map<string, string>([
["+16660000000", "Bob"],
["+17770000000", "Carol"],
]);
const formatted = formatGroupMembers({
participants: ["+1 (555) 000-0000", "+15550000000", "+16660000000"],
roster,
});
expect(formatted).toBe("+15550000000, Bob (+16660000000), Carol (+17770000000)");
});
it("falls back to sender when no participants or roster are available", () => {
const formatted = formatGroupMembers({
participants: [],
roster: undefined,
fallbackE164: "+1 (555) 222-3333",
});
expect(formatted).toBe("+15552223333");
});
it("returns undefined when no members can be resolved", () => {
expect(
formatGroupMembers({
participants: [],
roster: undefined,
}),
).toBeUndefined();
});
});
@@ -1,127 +0,0 @@
// Whatsapp tests cover inbound context plugin behavior.
import { describe, expect, it } from "vitest";
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
import {
resolveVisibleWhatsAppGroupHistory,
resolveVisibleWhatsAppReplyContext,
} from "./inbound-context.js";
type ReplyContextParams = Parameters<typeof resolveVisibleWhatsAppReplyContext>[0];
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",
},
admission: {
accountId: "default",
conversation: {
kind: "group",
id: "123@g.us",
},
sender: {
id: "111@s.whatsapp.net",
},
senderAccess: {
reasonCode: "group_policy_allowed",
},
},
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", () => {
const history = resolveVisibleWhatsAppGroupHistory({
history: [
{
sender: "Alice (+111)",
body: "Allowed context",
senderJid: "111@s.whatsapp.net",
},
{
sender: "Mallory (+999)",
body: "Blocked context",
senderJid: "999@s.whatsapp.net",
},
],
mode: "allowlist",
groupPolicy: "allowlist",
groupAllowFrom: ["+111"],
});
expect(history).toEqual([
{
sender: "Alice (+111)",
body: "Allowed context",
senderJid: "111@s.whatsapp.net",
},
]);
});
it("redacts blocked quoted replies in allowlist mode", () => {
const reply = resolveVisibleWhatsAppReplyContext({
msg: makeBlockedQuotedReplyMessage("msg-reply-1"),
mode: "allowlist",
groupPolicy: "allowlist",
groupAllowFrom: ["+111"],
});
expect(reply).toBeNull();
});
it("keeps blocked quoted replies in allowlist_quote mode", () => {
const reply = resolveVisibleWhatsAppReplyContext({
msg: makeBlockedQuotedReplyMessage("msg-reply-2"),
mode: "allowlist_quote",
groupPolicy: "allowlist",
groupAllowFrom: ["+111"],
});
expect(reply).toEqual({
id: "blocked-reply",
body: "Blocked quoted text",
sender: {
jid: "999@s.whatsapp.net",
lid: null,
e164: "+999",
label: "Mallory (+999)",
},
});
});
it("renders structured quoted media only at the visible preview boundary", () => {
const msg = makeBlockedQuotedReplyMessage("msg-reply-media");
msg.quote = {
context: {
id: "quoted-sticker",
body: "",
media: { contentType: "image/webp", kind: "sticker" },
sender: { jid: "111@s.whatsapp.net", label: "Alice (+111)" },
},
};
const reply = resolveVisibleWhatsAppReplyContext({
msg,
mode: "allowlist",
groupPolicy: "allowlist",
groupAllowFrom: ["+111"],
});
expect(reply?.body).toBe("<media:sticker>");
expect(reply?.media).toEqual({ contentType: "image/webp", kind: "sticker" });
});
});
@@ -487,10 +487,10 @@ function finalizedContext(
function makeReplyLogger(): BufferedReplyParams["replyLogger"] {
return {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as never;
}
@@ -1762,26 +1762,26 @@ describe("whatsapp inbound dispatch", () => {
expect(rememberSentText).not.toHaveBeenCalled();
});
it("maps WhatsApp streaming.block.enabled=true to disableBlockStreaming=false", async () => {
await dispatchBufferedReply();
expect(getCapturedReplyOptions()?.disableBlockStreaming).toBe(false);
});
it("maps WhatsApp streaming.block.enabled=false to disableBlockStreaming=true", async () => {
await dispatchBufferedReply({
it.each([
{
name: "maps WhatsApp streaming.block.enabled=true to disableBlockStreaming=false",
cfg: undefined,
expected: false,
},
{
name: "maps WhatsApp streaming.block.enabled=false to disableBlockStreaming=true",
cfg: { channels: { whatsapp: { streaming: { block: { enabled: false } } } } } as never,
});
expect(getCapturedReplyOptions()?.disableBlockStreaming).toBe(true);
});
it("leaves disableBlockStreaming undefined when WhatsApp block streaming is unset", async () => {
await dispatchBufferedReply({
expected: true,
},
{
name: "leaves disableBlockStreaming undefined when WhatsApp block streaming is unset",
cfg: { channels: { whatsapp: {} } } as never,
});
expected: undefined,
},
])("$name", async ({ cfg, expected }) => {
await dispatchBufferedReply(cfg ? { cfg } : {});
expect(getCapturedReplyOptions()?.disableBlockStreaming).toBeUndefined();
expect(getCapturedReplyOptions()?.disableBlockStreaming).toBe(expected);
});
it("leaves WhatsApp direct reply mode unset by default", async () => {
@@ -1850,37 +1850,42 @@ describe("whatsapp inbound dispatch", () => {
});
});
it("suppresses typing for message-tool-only group chat without mention", async () => {
it.each([
{
name: "suppresses typing for message-tool-only group chat without mention",
chatType: "group" as const,
wasMentioned: false,
expected: true,
},
{
name: "does not suppress typing for group chat when mentioned",
chatType: "group" as const,
wasMentioned: true,
expected: false,
},
{
name: "does not suppress typing for direct chat",
chatType: "direct" as const,
wasMentioned: undefined,
expected: false,
},
])("$name", async ({ chatType, wasMentioned, expected }) => {
const isGroup = chatType === "group";
await dispatchBufferedReply({
context: { Body: "hi", ChatType: "group", WasMentioned: false },
context: {
Body: wasMentioned ? "@bot hi" : "hi",
ChatType: chatType,
...(wasMentioned === undefined ? {} : { WasMentioned: wasMentioned }),
},
msg: makeMsg({
admission: groupAdmission("120363000000000000@g.us"),
wasMentioned: false,
admission: isGroup
? groupAdmission("120363000000000000@g.us")
: directAdmission("+15550001000"),
...(wasMentioned === undefined ? {} : { wasMentioned }),
}),
});
expect(getCapturedReplyOptions()?.suppressTyping).toBe(true);
});
it("does not suppress typing for group chat when mentioned", async () => {
await dispatchBufferedReply({
context: { Body: "@bot hi", ChatType: "group", WasMentioned: true },
msg: makeMsg({
admission: groupAdmission("120363000000000000@g.us"),
wasMentioned: true,
}),
});
expect(getCapturedReplyOptions()?.suppressTyping).toBe(false);
});
it("does not suppress typing for direct chat", async () => {
await dispatchBufferedReply({
context: { Body: "hi", ChatType: "direct" },
msg: makeMsg({ admission: directAdmission("+15550001000") }),
});
expect(getCapturedReplyOptions()?.suppressTyping).toBe(false);
expect(getCapturedReplyOptions()?.suppressTyping).toBe(expected);
});
it("treats block-only turns as visible replies instead of silent turns", async () => {
@@ -2032,12 +2037,7 @@ describe("whatsapp inbound dispatch", () => {
});
it("logs delivery failures from the shared dispatcher with WhatsApp context", async () => {
const replyLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as unknown as BufferedReplyParams["replyLogger"];
const replyLogger = makeReplyLogger();
const error = new Error("send failed");
await dispatchBufferedReply({
@@ -2071,12 +2071,7 @@ describe("whatsapp inbound dispatch", () => {
});
it("preserves Error subclass own-enumerable fields (e.g. Boom output) in the logged err", async () => {
const replyLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as unknown as BufferedReplyParams["replyLogger"];
const replyLogger = makeReplyLogger();
class BoomLikeError extends Error {
output: { statusCode: number; payload: { error: string } };
@@ -2121,152 +2116,101 @@ describe("whatsapp inbound dispatch", () => {
);
});
it("logs delivery failures with non-Error rejection values via pass-through", async () => {
const replyLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as unknown as BufferedReplyParams["replyLogger"];
await dispatchBufferedReply({
it.each([
{
name: "logs delivery failures with non-Error rejection values via pass-through",
rejection: "plain string rejection",
replyKind: "block" as const,
connectionId: "conn-2",
msg: makeMsg({
admission: directAdmission("+15550003000"),
event: { id: "msg-2" },
platform: {
recipientJid: "+15550004000",
chatJid: "15550003000@s.whatsapp.net",
},
}),
replyLogger,
});
await getCapturedOnError()?.("plain string rejection", { kind: "block" });
expect(replyLogger["error"]).toHaveBeenCalledWith(
expect.objectContaining({
err: "plain string rejection",
replyKind: "block",
correlationId: "msg-2",
}),
"auto-reply delivery failed",
);
});
it("preserves structured object rejections so diagnostic fields stay queryable", async () => {
const replyLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
} as unknown as BufferedReplyParams["replyLogger"];
await dispatchBufferedReply({
messageId: "msg-2",
conversationId: "+15550003000",
recipientJid: "+15550004000",
},
{
name: "preserves structured object rejections so diagnostic fields stay queryable",
rejection: {
error: { message: "wrapped failure", code: "BAILEYS_NACK" },
attempt: 2,
},
replyKind: "tool" as const,
connectionId: "conn-3",
msg: makeMsg({
admission: directAdmission("+15550005000"),
event: { id: "msg-3" },
platform: {
recipientJid: "+15550006000",
chatJid: "15550005000@s.whatsapp.net",
},
}),
replyLogger,
});
messageId: "msg-3",
conversationId: "+15550005000",
recipientJid: "+15550006000",
},
])(
"$name",
async ({ rejection, replyKind, connectionId, messageId, conversationId, recipientJid }) => {
const replyLogger = makeReplyLogger();
await dispatchBufferedReply({
connectionId,
msg: makeMsg({
admission: directAdmission(conversationId),
event: { id: messageId },
platform: {
recipientJid,
chatJid: `${conversationId.slice(1)}@s.whatsapp.net`,
},
}),
replyLogger,
});
const objectRejection = {
error: { message: "wrapped failure", code: "BAILEYS_NACK" },
attempt: 2,
};
await getCapturedOnError()?.(rejection, { kind: replyKind });
await getCapturedOnError()?.(objectRejection, { kind: "tool" });
expect(replyLogger["error"]).toHaveBeenCalledWith(
expect.objectContaining({ err: rejection, replyKind, correlationId: messageId }),
"auto-reply delivery failed",
);
},
);
expect(replyLogger["error"]).toHaveBeenCalledWith(
expect.objectContaining({
err: objectRejection,
replyKind: "tool",
correlationId: "msg-3",
}),
"auto-reply delivery failed",
);
});
it("updates main last route for DM when session key matches main session key", async () => {
const updateLastRoute = vi.fn();
updateWhatsAppMainLastRoute({
backgroundTasks: new Set(),
cfg: {} as never,
ctx: { Body: "hello" },
it.each([
{
name: "updates main last route for DM when session key matches main session key",
dmRouteTarget: "+1000",
pinnedMainDmRecipient: null,
route: makeRoute(),
updateLastRoute,
warn: () => {},
});
expect(updateLastRoute).toHaveBeenCalledTimes(1);
});
it("does not update main last route for isolated DM scope sessions", async () => {
const updateLastRoute = vi.fn();
updateWhatsAppMainLastRoute({
backgroundTasks: new Set(),
cfg: {} as never,
ctx: { Body: "hello" },
route: {},
expectedCalls: 1,
},
{
name: "does not update main last route for isolated DM scope sessions",
dmRouteTarget: "+3000",
pinnedMainDmRecipient: null,
route: makeRoute({
route: {
sessionKey: "agent:main:whatsapp:dm:+1000:peer:+3000",
mainSessionKey: "agent:main:whatsapp:direct:+1000",
}),
updateLastRoute,
warn: () => {},
});
expect(updateLastRoute).not.toHaveBeenCalled();
});
it("does not update main last route for non-owner sender when main DM scope is pinned", async () => {
const updateLastRoute = vi.fn();
updateWhatsAppMainLastRoute({
backgroundTasks: new Set(),
cfg: {} as never,
ctx: { Body: "hello" },
},
expectedCalls: 0,
},
{
name: "does not update main last route for non-owner sender when main DM scope is pinned",
dmRouteTarget: "+3000",
pinnedMainDmRecipient: "+1000",
route: makeRoute({
sessionKey: "agent:main:main",
mainSessionKey: "agent:main:main",
}),
updateLastRoute,
warn: () => {},
});
expect(updateLastRoute).not.toHaveBeenCalled();
});
it("updates main last route for owner sender when main DM scope is pinned", async () => {
route: { sessionKey: "agent:main:main", mainSessionKey: "agent:main:main" },
expectedCalls: 0,
},
{
name: "updates main last route for owner sender when main DM scope is pinned",
dmRouteTarget: "+1000",
pinnedMainDmRecipient: "+1000",
route: { sessionKey: "agent:main:main", mainSessionKey: "agent:main:main" },
expectedCalls: 1,
},
])("$name", ({ dmRouteTarget, pinnedMainDmRecipient, route, expectedCalls }) => {
const updateLastRoute = vi.fn();
updateWhatsAppMainLastRoute({
backgroundTasks: new Set(),
cfg: {} as never,
ctx: { Body: "hello" },
dmRouteTarget: "+1000",
pinnedMainDmRecipient: "+1000",
route: makeRoute({
sessionKey: "agent:main:main",
mainSessionKey: "agent:main:main",
}),
dmRouteTarget,
pinnedMainDmRecipient,
route: makeRoute(route),
updateLastRoute,
warn: () => {},
});
expect(updateLastRoute).toHaveBeenCalledTimes(1);
expect(updateLastRoute).toHaveBeenCalledTimes(expectedCalls);
});
it("resolves DM route targets from the sender first and the chat JID second", async () => {
@@ -0,0 +1,326 @@
// Whatsapp tests cover inbound context plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
import { createEchoTracker } from "./echo.js";
import { formatGroupMembers, noteGroupMember } from "./group-members.js";
import {
resolveVisibleWhatsAppGroupHistory,
resolveVisibleWhatsAppReplyContext,
} from "./inbound-context.js";
import { trackBackgroundTask } from "./last-route.js";
import { projectPreparedChannelInbound, type PreparedChannelInbound } from "./prepared-inbound.js";
type ReplyContextParams = Parameters<typeof resolveVisibleWhatsAppReplyContext>[0];
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",
},
admission: {
accountId: "default",
conversation: {
kind: "group",
id: "123@g.us",
},
sender: {
id: "111@s.whatsapp.net",
},
senderAccess: {
reasonCode: "group_policy_allowed",
},
},
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", () => {
const history = resolveVisibleWhatsAppGroupHistory({
history: [
{
sender: "Alice (+111)",
body: "Allowed context",
senderJid: "111@s.whatsapp.net",
},
{
sender: "Mallory (+999)",
body: "Blocked context",
senderJid: "999@s.whatsapp.net",
},
],
mode: "allowlist",
groupPolicy: "allowlist",
groupAllowFrom: ["+111"],
});
expect(history).toEqual([
{
sender: "Alice (+111)",
body: "Allowed context",
senderJid: "111@s.whatsapp.net",
},
]);
});
it("redacts blocked quoted replies in allowlist mode", () => {
const reply = resolveVisibleWhatsAppReplyContext({
msg: makeBlockedQuotedReplyMessage("msg-reply-1"),
mode: "allowlist",
groupPolicy: "allowlist",
groupAllowFrom: ["+111"],
});
expect(reply).toBeNull();
});
it("keeps blocked quoted replies in allowlist_quote mode", () => {
const reply = resolveVisibleWhatsAppReplyContext({
msg: makeBlockedQuotedReplyMessage("msg-reply-2"),
mode: "allowlist_quote",
groupPolicy: "allowlist",
groupAllowFrom: ["+111"],
});
expect(reply).toEqual({
id: "blocked-reply",
body: "Blocked quoted text",
sender: {
jid: "999@s.whatsapp.net",
lid: null,
e164: "+999",
label: "Mallory (+999)",
},
});
});
it("renders structured quoted media only at the visible preview boundary", () => {
const msg = makeBlockedQuotedReplyMessage("msg-reply-media");
msg.quote = {
context: {
id: "quoted-sticker",
body: "",
media: { contentType: "image/webp", kind: "sticker" },
sender: { jid: "111@s.whatsapp.net", label: "Alice (+111)" },
},
};
const reply = resolveVisibleWhatsAppReplyContext({
msg,
mode: "allowlist",
groupPolicy: "allowlist",
groupAllowFrom: ["+111"],
});
expect(reply?.body).toBe("<media:sticker>");
expect(reply?.media).toEqual({ contentType: "image/webp", kind: "sticker" });
});
});
describe("createEchoTracker", () => {
it("keeps verbose previews UTF-16 safe without changing the tracked text", () => {
const logVerbose = vi.fn();
const tracker = createEchoTracker({ logVerbose });
const prefix = "x".repeat(49);
const text = `${prefix}😀tail`;
tracker.rememberText(text, { logVerboseMessage: true });
expect(logVerbose).toHaveBeenCalledExactlyOnceWith(
`Added to echo detection set (size now: 1): ${prefix}...`,
);
expect(tracker.has(text)).toBe(true);
});
it("keeps identical text isolated to its originating conversation", () => {
const tracker = createEchoTracker({});
tracker.rememberText("Done.", { conversationId: "+1000" });
expect(tracker.has("Done.", "+1000")).toBe(true);
expect(tracker.has("Done.", "+3000")).toBe(false);
tracker.forget("Done.", "+1000");
expect(tracker.has("Done.", "+1000")).toBe(false);
});
it("keeps combined-message deduplication independent of conversation-scoped text", () => {
const tracker = createEchoTracker({});
const combinedKey = tracker.buildCombinedKey({
sessionKey: "agent:main:whatsapp:+1000",
combinedBody: "first\nsecond",
});
tracker.rememberText("Done.", {
conversationId: "+1000",
combinedBody: "first\nsecond",
combinedBodySessionKey: "agent:main:whatsapp:+1000",
});
expect(tracker.has(combinedKey)).toBe(true);
expect(tracker.has("Done.", "+1000")).toBe(true);
tracker.forget(combinedKey);
expect(tracker.has(combinedKey)).toBe(false);
expect(tracker.has("Done.", "+1000")).toBe(true);
});
});
describe("group member display", () => {
it("normalizes member phone numbers before storing", () => {
const groupMemberNames = new Map<string, Map<string, string>>();
noteGroupMember(groupMemberNames, "g1", "+1 (555) 123-4567", "Alice");
expect(groupMemberNames.get("g1")?.get("+15551234567")).toBe("Alice");
});
it("ignores incomplete member values", () => {
const groupMemberNames = new Map<string, Map<string, string>>();
noteGroupMember(groupMemberNames, "g1", undefined, "Alice");
noteGroupMember(groupMemberNames, "g1", "+15551234567", undefined);
expect(groupMemberNames.get("g1")).toBeUndefined();
});
it.each([
{
name: "deduplicates participants and appends named roster members",
params: {
participants: ["+1 (555) 000-0000", "+15550000000", "+16660000000"],
roster: new Map([
["+16660000000", "Bob"],
["+17770000000", "Carol"],
]),
},
expected: "+15550000000, Bob (+16660000000), Carol (+17770000000)",
},
{
name: "falls back to sender when no participants or roster are available",
params: {
participants: [],
roster: undefined,
fallbackE164: "+1 (555) 222-3333",
},
expected: "+15552223333",
},
{
name: "returns undefined when no members can be resolved",
params: { participants: [], roster: undefined },
expected: undefined,
},
])("$name", ({ params, expected }) => {
expect(formatGroupMembers(params)).toBe(expected);
});
});
describe("trackBackgroundTask", () => {
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => {
unhandledRejections.push(reason);
};
afterEach(() => {
process.off("unhandledRejection", onUnhandledRejection);
unhandledRejections.length = 0;
});
it("does not leak unhandled rejections when a tracked task fails", async () => {
process.on("unhandledRejection", onUnhandledRejection);
const backgroundTasks = new Set<Promise<unknown>>();
let rejectTask: ((reason?: unknown) => void) | undefined;
const task = new Promise<void>((_resolve, reject) => {
rejectTask = reject;
});
trackBackgroundTask(backgroundTasks, task);
expect(backgroundTasks.size).toBe(1);
if (!rejectTask) {
throw new Error("Expected tracked task reject callback to be initialized");
}
rejectTask(new Error("boom"));
await Promise.allSettled([task]);
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(backgroundTasks.size).toBe(0);
expect(unhandledRejections).toStrictEqual([]);
});
});
describe("WhatsApp prepared inbound", () => {
it("projects portable facts without WhatsApp transport state", () => {
const inbound = {
channel: "whatsapp",
accountId: "work",
event: { id: "event-1", fullId: "whatsapp:event-1", timestamp: 1_710_000_000 },
from: "whatsapp:user:u1",
sender: { id: "u1", name: "Alice" },
conversation: { kind: "group", id: "room-1", label: "Example Room" },
route: {
agentId: "main",
accountId: "work",
routeSessionKey: "agent:main:whatsapp:group:room-1",
},
reply: { to: "whatsapp:room:room-1", replyToId: "quoted-1" },
message: {
body: "agent body",
bodyForAgent: "agent body",
rawBody: "raw body",
commandBody: "/status",
},
command: {
kind: "text-slash",
body: "/status",
authorization: { kind: "denied", reason: "sender_not_allowed" },
},
media: [{ path: "/tmp/example.jpg", contentType: "image/jpeg", kind: "image" }],
context: { senderE164: "+15550001111" },
} satisfies PreparedChannelInbound;
const projected = projectPreparedChannelInbound({
inbound,
control: { messageReceivedHooks: "core" },
});
expect(projected.input).toEqual({
id: "event-1",
timestamp: 1_710_000_000,
rawText: "raw body",
textForAgent: "agent body",
textForCommands: "/status",
raw: inbound,
});
expect(projected.context).toMatchObject({
MessageSid: "event-1",
MessageSidFull: "whatsapp:event-1",
BodyForAgent: "agent body",
RawBody: "raw body",
CommandBody: "/status",
ReplyToId: "quoted-1",
CommandAuthorized: false,
ConversationLabel: "Example Room",
GroupSubject: "Example Room",
SenderE164: "+15550001111",
media: [{ path: "/tmp/example.jpg", contentType: "image/jpeg", kind: "image" }],
});
});
});
@@ -1,43 +0,0 @@
// Whatsapp tests cover last route plugin behavior.
import { afterEach, describe, expect, it } from "vitest";
import { trackBackgroundTask } from "./last-route.js";
const waitForTaskCleanup = async (task: Promise<unknown>) => {
await Promise.allSettled([task]);
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
};
describe("trackBackgroundTask", () => {
const unhandledRejections: unknown[] = [];
const onUnhandledRejection = (reason: unknown) => {
unhandledRejections.push(reason);
};
afterEach(() => {
process.off("unhandledRejection", onUnhandledRejection);
unhandledRejections.length = 0;
});
it("does not leak unhandled rejections when a tracked task fails", async () => {
process.on("unhandledRejection", onUnhandledRejection);
const backgroundTasks = new Set<Promise<unknown>>();
let rejectTask: ((reason?: unknown) => void) | undefined;
const task = new Promise<void>((_resolve, reject) => {
rejectTask = reject;
});
trackBackgroundTask(backgroundTasks, task);
expect(backgroundTasks.size).toBe(1);
if (!rejectTask) {
throw new Error("Expected tracked task reject callback to be initialized");
}
rejectTask(new Error("boom"));
await waitForTaskCleanup(task);
expect(backgroundTasks.size).toBe(0);
expect(unhandledRejections).toStrictEqual([]);
});
});
@@ -1,92 +0,0 @@
import { describe, expect, it } from "vitest";
import { projectPreparedChannelInbound, type PreparedChannelInbound } from "./prepared-inbound.js";
describe("WhatsApp prepared inbound", () => {
it("projects portable facts without WhatsApp transport state", () => {
const inbound = {
channel: "whatsapp",
accountId: "work",
event: {
id: "event-1",
fullId: "whatsapp:event-1",
timestamp: 1_710_000_000,
},
from: "whatsapp:user:u1",
sender: {
id: "u1",
name: "Alice",
},
conversation: {
kind: "group",
id: "room-1",
label: "Example Room",
},
route: {
agentId: "main",
accountId: "work",
routeSessionKey: "agent:main:whatsapp:group:room-1",
},
reply: {
to: "whatsapp:room:room-1",
replyToId: "quoted-1",
},
message: {
body: "agent body",
bodyForAgent: "agent body",
rawBody: "raw body",
commandBody: "/status",
},
command: {
kind: "text-slash",
body: "/status",
authorization: {
kind: "denied",
reason: "sender_not_allowed",
},
},
media: [
{
path: "/tmp/example.jpg",
contentType: "image/jpeg",
kind: "image",
},
],
context: {
senderE164: "+15550001111",
},
} satisfies PreparedChannelInbound;
const projected = projectPreparedChannelInbound({
inbound,
control: { messageReceivedHooks: "core" },
});
expect(projected.input).toEqual({
id: "event-1",
timestamp: 1_710_000_000,
rawText: "raw body",
textForAgent: "agent body",
textForCommands: "/status",
raw: inbound,
});
expect(projected.context).toMatchObject({
MessageSid: "event-1",
MessageSidFull: "whatsapp:event-1",
BodyForAgent: "agent body",
RawBody: "raw body",
CommandBody: "/status",
ReplyToId: "quoted-1",
CommandAuthorized: false,
ConversationLabel: "Example Room",
GroupSubject: "Example Room",
SenderE164: "+15550001111",
media: [
{
path: "/tmp/example.jpg",
contentType: "image/jpeg",
kind: "image",
},
],
});
});
});
@@ -308,61 +308,57 @@ describe("processMessage audio preflight transcription", () => {
});
});
it("keeps the empty caption and audio fact when transcription fails", async () => {
transcribeFirstAudioMock.mockRejectedValueOnce(new Error("provider unavailable"));
it.each([
{
name: "keeps the empty caption and audio fact when transcription fails",
arrange: () =>
transcribeFirstAudioMock.mockRejectedValueOnce(new Error("provider unavailable")),
},
{
name: "keeps the empty caption when transcription returns undefined",
arrange: () => transcribeFirstAudioMock.mockResolvedValueOnce(undefined),
},
])("$name", async ({ arrange }) => {
arrange();
await processMessage(makeParams());
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
expectContextFields(firstDispatchContext(), {
Body: "",
BodyForAgent: "",
});
expectContextFields(firstDispatchContext(), { Body: "", BodyForAgent: "" });
});
it("keeps the empty caption when transcription returns undefined", async () => {
transcribeFirstAudioMock.mockResolvedValueOnce(undefined);
await processMessage(makeParams());
expect(transcribeFirstAudioMock).toHaveBeenCalledTimes(1);
expectContextFields(firstDispatchContext(), {
Body: "",
BodyForAgent: "",
});
});
it("does not call transcribeFirstAudio when mediaType is not audio", async () => {
await processMessage(
makeParams({ body: "<media:image>", mediaType: "image/jpeg", mediaPath: "/tmp/img.jpg" }),
);
it.each([
{
name: "does not call transcribeFirstAudio when mediaType is not audio",
overrides: {
body: "<media:image>",
mediaType: "image/jpeg",
mediaPath: "/tmp/img.jpg",
},
assertEmptyBody: false,
},
{
name: "does not call transcribeFirstAudio when audio has a caption",
overrides: { body: "hello there", mediaType: "audio/ogg; codecs=opus" },
assertEmptyBody: false,
},
{
name: "does not call transcribeFirstAudio when mediaPath is absent",
overrides: { mediaPath: undefined },
assertEmptyBody: false,
},
{
name: "does not call transcribeFirstAudio when msg.mediaType is absent",
overrides: { mediaType: undefined, mediaPath: "/tmp/voice.ogg" },
assertEmptyBody: true,
},
])("$name", async ({ overrides, assertEmptyBody }) => {
await processMessage(makeParams(overrides));
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
});
it("does not call transcribeFirstAudio when audio has a caption", async () => {
await processMessage(makeParams({ body: "hello there", mediaType: "audio/ogg; codecs=opus" }));
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
});
it("does not call transcribeFirstAudio when mediaPath is absent", async () => {
await processMessage(makeParams({ mediaPath: undefined }));
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
});
it("does not call transcribeFirstAudio when msg.mediaType is absent", async () => {
await processMessage(makeParams({ mediaType: undefined, mediaPath: "/tmp/voice.ogg" }));
expect(transcribeFirstAudioMock).not.toHaveBeenCalled();
// Empty body passes through without a classified audio fact.
expectContextFields(firstDispatchContext(), {
Body: "",
});
if (assertEmptyBody) {
expectContextFields(firstDispatchContext(), { Body: "" });
}
});
it("does not use transcript body for command detection", async () => {
@@ -342,69 +342,65 @@ describe("processMessage group system prompt wiring", () => {
).toBe("from config");
});
it("marks detected WhatsApp slash messages as text command turns", async () => {
resolvePolicyMock.mockReturnValue(makePolicy(makeAccount()));
isControlCommandMessageMock.mockReturnValue(true);
shouldComputeCommandAuthorizedMock.mockReturnValue(true);
await callProcessMessage({
msg: makeBaseMsg({ body: "/status" }),
});
expect(shouldComputeCommandAuthorizedMock).toHaveBeenCalledWith("/status", {});
expect(isControlCommandMessageMock).toHaveBeenCalledWith("/status", {});
expect(mockCallArg(buildContextMock, "buildWhatsAppInboundContext")).toMatchObject({
command: {
kind: "text-slash",
authorization: { kind: "authorized" },
body: "/status",
it.each([
{
name: "marks detected WhatsApp slash messages as text command turns",
message: { body: "/status" },
commandBody: "/status",
isControlCommand: true,
expectedContext: {
command: {
kind: "text-slash",
authorization: { kind: "authorized" },
body: "/status",
},
rawBody: "/status",
},
rawBody: "/status",
});
});
it("keeps generated media notices out of command input", async () => {
resolvePolicyMock.mockReturnValue(makePolicy(makeAccount()));
isControlCommandMessageMock.mockReturnValue(true);
shouldComputeCommandAuthorizedMock.mockReturnValue(true);
await callProcessMessage({
msg: makeBaseMsg({
},
{
name: "keeps generated media notices out of command input",
message: {
body: "/reset\n\n[whatsapp attachment unavailable]",
commandBody: "/reset",
}),
});
expect(shouldComputeCommandAuthorizedMock).toHaveBeenCalledWith("/reset", {});
expect(isControlCommandMessageMock).toHaveBeenCalledWith("/reset", {});
expect(mockCallArg(buildContextMock, "buildWhatsAppInboundContext")).toMatchObject({
bodyForAgent: "/reset\n\n[whatsapp attachment unavailable]",
command: {
kind: "text-slash",
authorization: { kind: "authorized" },
body: "/reset",
},
rawBody: "/reset",
});
});
it("checks auth for inline command tokens without marking them as command-source turns", async () => {
commandBody: "/reset",
isControlCommand: true,
expectedContext: {
bodyForAgent: "/reset\n\n[whatsapp attachment unavailable]",
command: {
kind: "text-slash",
authorization: { kind: "authorized" },
body: "/reset",
},
rawBody: "/reset",
},
},
{
name: "checks auth for inline command tokens without marking them as command-source turns",
message: { body: "please inspect `/tmp/foo`" },
commandBody: "please inspect `/tmp/foo`",
isControlCommand: false,
expectedContext: {
command: {
kind: "normal",
authorization: { kind: "authorized" },
body: "please inspect `/tmp/foo`",
},
rawBody: "please inspect `/tmp/foo`",
},
},
])("$name", async ({ message, commandBody, isControlCommand, expectedContext }) => {
resolvePolicyMock.mockReturnValue(makePolicy(makeAccount()));
isControlCommandMessageMock.mockReturnValue(false);
isControlCommandMessageMock.mockReturnValue(isControlCommand);
shouldComputeCommandAuthorizedMock.mockReturnValue(true);
await callProcessMessage({
msg: makeBaseMsg({ body: "please inspect `/tmp/foo`" }),
});
await callProcessMessage({ msg: makeBaseMsg(message) });
expect(mockCallArg(buildContextMock, "buildWhatsAppInboundContext")).toMatchObject({
command: {
kind: "normal",
authorization: { kind: "authorized" },
body: "please inspect `/tmp/foo`",
},
rawBody: "please inspect `/tmp/foo`",
});
expect(shouldComputeCommandAuthorizedMock).toHaveBeenCalledWith(commandBody, {});
expect(isControlCommandMessageMock).toHaveBeenCalledWith(commandBody, {});
expect(mockCallArg(buildContextMock, "buildWhatsAppInboundContext")).toMatchObject(
expectedContext,
);
});
it("passes pending group history from the history window into inbound context", async () => {
@@ -3,7 +3,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import { resolveWhatsAppAckEmoji } from "./ack-emoji.js";
import { maybeSendAckReaction } from "./ack-reaction.js";
import { createWhatsAppStatusReactionController } from "./status-reaction.js";
const hoisted = vi.hoisted(() => ({
sendReactionWhatsApp: vi.fn(async () => undefined),
@@ -19,6 +21,10 @@ vi.mock("./group-activation.js", () => ({
type TestMsgOverrides = NonNullable<Parameters<typeof createTestWebInboundMessage>[0]>;
type AckReactionConfig = NonNullable<
NonNullable<NonNullable<OpenClawConfig["channels"]>["whatsapp"]>["ackReaction"]
>;
function createMessage(overrides: TestMsgOverrides = {}): AdmittedWebInboundMessage {
return createTestWebInboundMessage({
event: { id: "msg-1" },
@@ -56,6 +62,57 @@ function createConfig(
} as OpenClawConfig;
}
function createAckEmojiConfig(ackReaction?: AckReactionConfig): OpenClawConfig {
const cfg = {
agents: {
list: [{ id: "agent", identity: { emoji: "🔥" } }],
},
channels: {
whatsapp: {},
},
} as OpenClawConfig;
if (ackReaction !== undefined) {
cfg.channels!.whatsapp!.ackReaction = ackReaction;
}
return cfg;
}
function resolveAckEmoji(cfg: OpenClawConfig, agentId = "agent") {
return resolveWhatsAppAckEmoji({
cfg,
agentId,
ackConfig: cfg.channels?.whatsapp?.ackReaction,
});
}
function createStatusConfig(
overrides: {
ackEmoji?: string;
agentEmoji?: string;
reactionLevel?: "off" | "ack";
workReactionLevel?: "off" | "ack";
} = {},
): OpenClawConfig {
return {
...(overrides.agentEmoji
? { agents: { entries: { agent: { identity: { emoji: overrides.agentEmoji } } } } }
: {}),
messages: {
ackReaction: overrides.ackEmoji ?? "👀",
ackReactionScope: "all",
statusReactions: { enabled: true },
},
channels: {
whatsapp: {
reactionLevel: overrides.reactionLevel ?? "ack",
...(overrides.workReactionLevel
? { accounts: { work: { reactionLevel: overrides.workReactionLevel } } }
: {}),
},
},
} as OpenClawConfig;
}
type AckReactionParams = Parameters<typeof maybeSendAckReaction>[0];
const runAckReaction = (overrides: Partial<AckReactionParams> = {}) =>
@@ -84,6 +141,48 @@ const expectAckReactionSent = (accountId: string, cfg: OpenClawConfig = createCo
);
};
describe("resolveWhatsAppAckEmoji", () => {
it.each([
{
name: "keeps missing ackReaction config disabled",
cfg: createAckEmojiConfig(),
expected: "",
},
{
name: "uses the configured WhatsApp emoji when present",
cfg: createAckEmojiConfig({ emoji: " 👀 ", direct: true, group: "mentions" }),
expected: "👀",
},
{
name: "falls back to the routed agent identity for an empty emoji",
cfg: createAckEmojiConfig({ emoji: " ", direct: true, group: "mentions" }),
expected: "🔥",
},
{
name: "falls back to the routed agent identity emoji when the ack object has no emoji",
cfg: createAckEmojiConfig({ direct: true, group: "mentions" }),
expected: "🔥",
},
{
name: "uses normalized agent ids for the identity fallback",
cfg: {
agents: { list: [{ id: "Agent", identity: { emoji: "🔥" } }] },
channels: { whatsapp: { ackReaction: { direct: true, group: "mentions" } } },
} as OpenClawConfig,
expected: "🔥",
},
{
name: "uses the default ack emoji when configured without an emoji or agent identity",
cfg: {
channels: { whatsapp: { ackReaction: { direct: true, group: "mentions" } } },
} as OpenClawConfig,
expected: "👀",
},
])("$name", ({ cfg, expected }) => {
expect(resolveAckEmoji(cfg)).toBe(expected);
});
});
describe("maybeSendAckReaction", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -297,3 +396,147 @@ describe("maybeSendAckReaction", () => {
);
});
});
describe("createWhatsAppStatusReactionController", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses the sender LID as the group reaction participant when no sender JID is available", async () => {
const cfg = createStatusConfig();
const controller = await createWhatsAppStatusReactionController({
cfg,
msg: createMessage({
platform: {
chatJid: "120363000000000000@g.us",
sender: { jid: null, lid: "277038292303944@lid" },
},
admission: {
conversation: { kind: "group", id: "120363000000000000@g.us" },
sender: { id: "277038292303944@lid" },
},
}),
agentId: "agent",
sessionKey: "whatsapp:default:120363000000000000@g.us",
verbose: false,
});
void controller?.setQueued();
await vi.waitFor(() => {
expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith(
"120363000000000000@g.us",
"msg-1",
"👀",
{
verbose: false,
fromMe: false,
participant: "277038292303944@lid",
accountId: "default",
cfg,
},
);
});
await controller?.clear();
expect(hoisted.sendReactionWhatsApp).toHaveBeenLastCalledWith(
"120363000000000000@g.us",
"msg-1",
"",
{
verbose: false,
fromMe: false,
participant: "277038292303944@lid",
accountId: "default",
cfg,
},
);
});
it("preserves self-authored direction for status set and clear", async () => {
const cfg = createStatusConfig();
const controller = await createWhatsAppStatusReactionController({
cfg,
msg: createMessage({
platform: {
chatJid: "15551234567@s.whatsapp.net",
recipientJid: "15559876543",
fromMe: true,
},
}),
agentId: "agent",
sessionKey: "whatsapp:default:15551234567",
verbose: false,
});
void controller?.setQueued();
await vi.waitFor(() => {
expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith(
"15551234567@s.whatsapp.net",
"msg-1",
"👀",
{
verbose: false,
fromMe: true,
accountId: "default",
cfg,
},
);
});
await controller?.clear();
expect(hoisted.sendReactionWhatsApp).toHaveBeenLastCalledWith(
"15551234567@s.whatsapp.net",
"msg-1",
"",
{
verbose: false,
fromMe: true,
accountId: "default",
cfg,
},
);
});
it.each([
{
name: "uses the canonical emoji preserved from agent identity",
cfg: createStatusConfig({ ackEmoji: "🔥", agentEmoji: "🔥" }),
msg: createMessage(),
sessionKey: "whatsapp:default:15551234567",
expectedEmoji: "🔥",
expectedAccountId: "default",
},
{
name: "uses the active account reactionLevel override from admission",
cfg: createStatusConfig({ reactionLevel: "off", workReactionLevel: "ack" }),
msg: createMessage({ admission: { accountId: "work" } }),
sessionKey: "whatsapp:work:15551234567",
expectedEmoji: "👀",
expectedAccountId: "work",
},
])("$name", async ({ cfg, msg, sessionKey, expectedEmoji, expectedAccountId }) => {
const controller = await createWhatsAppStatusReactionController({
cfg,
msg,
agentId: "agent",
sessionKey,
verbose: false,
});
void controller?.setQueued();
await vi.waitFor(() => {
expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith(
"15551234567@s.whatsapp.net",
"msg-1",
expectedEmoji,
{
verbose: false,
fromMe: false,
accountId: expectedAccountId,
cfg,
},
);
});
await controller?.clear();
});
});
@@ -1,275 +0,0 @@
// 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 { createTestWebInboundMessage } from "../../inbound/test-message.test-helper.js";
import type { AdmittedWebInboundMessage } from "../../inbound/types.js";
import { createWhatsAppStatusReactionController } from "./status-reaction.js";
const hoisted = vi.hoisted(() => ({
sendReactionWhatsApp: vi.fn(async () => undefined),
}));
vi.mock("../../send.js", () => ({
sendReactionWhatsApp: hoisted.sendReactionWhatsApp,
}));
vi.mock("./group-activation.js", () => ({
resolveGroupActivationFor: vi.fn(async () => "always"),
}));
type TestMsgOverrides = NonNullable<Parameters<typeof createTestWebInboundMessage>[0]>;
function createMessage(overrides: TestMsgOverrides = {}): AdmittedWebInboundMessage {
return createTestWebInboundMessage({
event: { id: "msg-1" },
platform: {
chatJid: "15551234567@s.whatsapp.net",
recipientJid: "15559876543",
fromMe: false,
},
admission: {
accountId: "default",
conversation: {
kind: "direct",
id: "15551234567",
},
sender: {
id: "15551234567",
},
},
...overrides,
});
}
describe("createWhatsAppStatusReactionController", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("uses the sender LID as the group reaction participant when no sender JID is available", async () => {
const cfg = {
messages: {
ackReaction: "👀",
ackReactionScope: "all",
statusReactions: {
enabled: true,
},
},
channels: {
whatsapp: {
reactionLevel: "ack",
},
},
} as OpenClawConfig;
const controller = await createWhatsAppStatusReactionController({
cfg,
msg: createMessage({
platform: {
chatJid: "120363000000000000@g.us",
sender: {
jid: null,
lid: "277038292303944@lid",
},
},
admission: {
conversation: {
kind: "group",
id: "120363000000000000@g.us",
},
sender: {
id: "277038292303944@lid",
},
},
}),
agentId: "agent",
sessionKey: "whatsapp:default:120363000000000000@g.us",
verbose: false,
});
void controller?.setQueued();
await vi.waitFor(() => {
expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith(
"120363000000000000@g.us",
"msg-1",
"👀",
{
verbose: false,
fromMe: false,
participant: "277038292303944@lid",
accountId: "default",
cfg,
},
);
});
await controller?.clear();
expect(hoisted.sendReactionWhatsApp).toHaveBeenLastCalledWith(
"120363000000000000@g.us",
"msg-1",
"",
{
verbose: false,
fromMe: false,
participant: "277038292303944@lid",
accountId: "default",
cfg,
},
);
});
it("uses the canonical emoji preserved from agent identity", async () => {
const cfg = {
agents: {
entries: { agent: { identity: { emoji: "🔥" } } },
},
messages: {
ackReaction: "🔥",
ackReactionScope: "all",
statusReactions: {
enabled: true,
},
},
channels: {
whatsapp: {
reactionLevel: "ack",
},
},
} as OpenClawConfig;
const controller = await createWhatsAppStatusReactionController({
cfg,
msg: createMessage(),
agentId: "agent",
sessionKey: "whatsapp:default:15551234567",
verbose: false,
});
void controller?.setQueued();
await vi.waitFor(() => {
expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith(
"15551234567@s.whatsapp.net",
"msg-1",
"🔥",
{
verbose: false,
fromMe: false,
accountId: "default",
cfg,
},
);
});
await controller?.clear();
});
it("preserves self-authored direction for status set and clear", async () => {
const cfg = {
messages: {
ackReaction: "👀",
ackReactionScope: "all",
statusReactions: {
enabled: true,
},
},
channels: {
whatsapp: {
reactionLevel: "ack",
},
},
} as OpenClawConfig;
const controller = await createWhatsAppStatusReactionController({
cfg,
msg: createMessage({
platform: {
chatJid: "15551234567@s.whatsapp.net",
recipientJid: "15559876543",
fromMe: true,
},
}),
agentId: "agent",
sessionKey: "whatsapp:default:15551234567",
verbose: false,
});
void controller?.setQueued();
await vi.waitFor(() => {
expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith(
"15551234567@s.whatsapp.net",
"msg-1",
"👀",
{
verbose: false,
fromMe: true,
accountId: "default",
cfg,
},
);
});
await controller?.clear();
expect(hoisted.sendReactionWhatsApp).toHaveBeenLastCalledWith(
"15551234567@s.whatsapp.net",
"msg-1",
"",
{
verbose: false,
fromMe: true,
accountId: "default",
cfg,
},
);
});
it("uses the active account reactionLevel override from admission", async () => {
const cfg = {
messages: {
ackReaction: "👀",
ackReactionScope: "all",
statusReactions: {
enabled: true,
},
},
channels: {
whatsapp: {
reactionLevel: "off",
accounts: {
work: {
reactionLevel: "ack",
},
},
},
},
} as OpenClawConfig;
const controller = await createWhatsAppStatusReactionController({
cfg,
msg: createMessage({
admission: {
accountId: "work",
},
}),
agentId: "agent",
sessionKey: "whatsapp:work:15551234567",
verbose: false,
});
void controller?.setQueued();
await vi.waitFor(() => {
expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith(
"15551234567@s.whatsapp.net",
"msg-1",
"👀",
{
verbose: false,
fromMe: false,
accountId: "work",
cfg,
},
);
});
await controller?.clear();
});
});
@@ -1,5 +1,4 @@
// Whatsapp plugin module implements monitor inbox.allows messages from senders allowfrom list support behavior.
import "./monitor-inbox.test-harness.js";
// WhatsApp monitor inbox access and echo behavior.
import { cleanMessage } from "baileys";
import { describe, expect, it, vi } from "vitest";
import { isRecentOutboundMessage } from "./inbound/dedupe.js";
@@ -1,283 +0,0 @@
// Whatsapp plugin module implements monitor inbox.append upsert support behavior.
import "./monitor-inbox.test-harness.js";
import { describe, expect, it, vi } from "vitest";
import {
installWebMonitorInboxUnitTestHooks,
settleInboundWork,
startInboxMonitor,
waitForMessageCalls,
} from "./monitor-inbox.test-harness.js";
describe("append upsert handling (#20952)", () => {
installWebMonitorInboxUnitTestHooks();
it("delivery coordinator processes recent append messages", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// Timestamp ~5 seconds ago — recent, should be processed.
const recentTs = Math.floor(Date.now() / 1000) - 5;
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "recent-1", fromMe: false, remoteJid: "120363@g.us" },
message: { conversation: "hello from group" },
messageTimestamp: recentTs,
pushName: "Tester",
},
],
});
await waitForMessageCalls(onMessage, 1);
expect(onMessage).toHaveBeenCalledTimes(1);
await listener.close();
});
it("delivery coordinator skips stale append messages", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// Timestamp 5 minutes ago — stale history sync, should be skipped.
const staleTs = Math.floor(Date.now() / 1000) - 300;
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "stale-1", fromMe: false, remoteJid: "120363@g.us" },
message: { conversation: "old history sync" },
messageTimestamp: staleTs,
pushName: "OldTester",
},
],
});
await settleInboundWork();
expect(onMessage).not.toHaveBeenCalled();
await listener.close();
});
it("delivery coordinator limits reconnect catch-up appends by dedupe age", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage, {
appendReplyWindow: {
afterMs: Date.now() - 30 * 60_000,
untilMs: Date.now() + 30 * 60_000,
maxAgeMs: 20 * 60_000,
},
});
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "catch-up-1", fromMe: false, remoteJid: "999@s.whatsapp.net" },
message: { conversation: "missed while reconnecting" },
messageTimestamp: Math.floor(Date.now() / 1000) - 15 * 60,
pushName: "Reconnect Tester",
},
],
});
await waitForMessageCalls(onMessage, 1);
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "catch-up-old", fromMe: false, remoteJid: "999@s.whatsapp.net" },
message: { conversation: "before the recovery window" },
messageTimestamp: Math.floor(Date.now() / 1000) - 25 * 60,
pushName: "Reconnect Tester",
},
],
});
await settleInboundWork();
expect(onMessage).toHaveBeenCalledTimes(1);
await listener.close();
});
it("delivery coordinator preserves fresh appends after catch-up expires", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage, {
appendReplyWindow: {
afterMs: Date.now() - 30 * 60_000,
untilMs: Date.now() - 1,
maxAgeMs: 20 * 60_000,
},
});
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "catch-up-late", fromMe: false, remoteJid: "999@s.whatsapp.net" },
message: { conversation: "arrived after recovery" },
messageTimestamp: Math.floor(Date.now() / 1000) - 5 * 60,
pushName: "Reconnect Tester",
},
],
});
await settleInboundWork();
expect(onMessage).not.toHaveBeenCalled();
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "fresh-after-catch-up", fromMe: false, remoteJid: "999@s.whatsapp.net" },
message: { conversation: "fresh after recovery" },
messageTimestamp: Math.floor(Date.now() / 1000),
pushName: "Reconnect Tester",
},
],
});
await waitForMessageCalls(onMessage, 1);
expect(onMessage).toHaveBeenCalledTimes(1);
await listener.close();
});
it("delivery coordinator processes distinct catch-up messages at the boundary", async () => {
// Baileys timestamps use whole seconds. Freeze this inclusive boundary so
// async monitor startup cannot age the fixture beyond maxAgeMs.
const nowMs = 1_700_000_000_000;
const dateNow = vi.spyOn(Date, "now").mockReturnValue(nowMs);
try {
const onMessage = vi.fn(async () => {});
const boundarySeconds = nowMs / 1000 - 20 * 60;
const { listener, sock } = await startInboxMonitor(onMessage, {
appendReplyWindow: {
afterMs: boundarySeconds * 1000,
untilMs: nowMs + 30 * 60_000,
maxAgeMs: 20 * 60_000,
},
});
try {
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: {
id: "catch-up-same-second",
fromMe: false,
remoteJid: "999@s.whatsapp.net",
},
message: { conversation: "same second, different message" },
messageTimestamp: boundarySeconds,
pushName: "Reconnect Tester",
},
],
});
await waitForMessageCalls(onMessage, 1);
expect(onMessage).toHaveBeenCalledTimes(1);
} finally {
await listener.close();
}
} finally {
dateNow.mockRestore();
}
});
it("delivery coordinator skips append messages with non-finite timestamps", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// NaN timestamp should be treated as 0 (stale) and skipped.
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "nan-1", fromMe: false, remoteJid: "120363@g.us" },
message: { conversation: "bad timestamp" },
messageTimestamp: Number.NaN,
pushName: "BadTs",
},
],
});
await settleInboundWork();
expect(onMessage).not.toHaveBeenCalled();
await listener.close();
});
it("delivery coordinator skips append messages with non-decimal timestamps", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
const recentTs = Math.floor(Date.now() / 1000) - 5;
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "hex-1", fromMe: false, remoteJid: "120363@g.us" },
message: { conversation: "hex timestamp" },
messageTimestamp: `0x${recentTs.toString(16)}`,
pushName: "HexTs",
},
],
});
await settleInboundWork();
expect(onMessage).not.toHaveBeenCalled();
await listener.close();
});
it("delivery coordinator handles Long-like protobuf timestamps", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// Baileys can deliver messageTimestamp as a Long object (from protobufjs).
// Number(longObj) calls valueOf() and returns the numeric value.
const recentTs = Math.floor(Date.now() / 1000) - 5;
const longLike = { low: recentTs, high: 0, unsigned: true, valueOf: () => recentTs };
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "long-1", fromMe: false, remoteJid: "120363@g.us" },
message: { conversation: "long timestamp" },
messageTimestamp: longLike,
pushName: "LongTs",
},
],
});
await waitForMessageCalls(onMessage, 1);
expect(onMessage).toHaveBeenCalledTimes(1);
await listener.close();
});
it("delivery coordinator always processes notify messages", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
// Very old timestamp but type=notify — should always be processed.
const oldTs = Math.floor(Date.now() / 1000) - 86400;
sock.ev.emit("messages.upsert", {
type: "notify",
messages: [
{
key: { id: "notify-1", fromMe: false, remoteJid: "999@s.whatsapp.net" },
message: { conversation: "normal message" },
messageTimestamp: oldTs,
pushName: "User",
},
],
});
await waitForMessageCalls(onMessage, 1);
expect(onMessage).toHaveBeenCalledTimes(1);
await listener.close();
});
});
@@ -0,0 +1,237 @@
// WhatsApp monitor inbox append behavior.
import { describe, expect, it, vi } from "vitest";
import {
installWebMonitorInboxUnitTestHooks,
settleInboundWork,
startInboxMonitor,
waitForMessageCalls,
} from "./monitor-inbox.test-harness.js";
function emitUpsert(
sock: { ev: { emit: (event: string, payload: unknown) => void } },
params: {
id: string;
body: string;
remoteJid: string;
type: "append" | "notify";
timestamp: unknown;
},
) {
sock.ev.emit("messages.upsert", {
type: params.type,
messages: [
{
key: { id: params.id, fromMe: false, remoteJid: params.remoteJid },
message: { conversation: params.body },
messageTimestamp: params.timestamp,
pushName: "Tester",
},
],
});
}
describe("append upsert handling (#20952)", () => {
installWebMonitorInboxUnitTestHooks();
it.each([
{
name: "delivery coordinator processes recent append messages",
id: "recent-1",
body: "hello from group",
remoteJid: "120363@g.us",
type: "append" as const,
timestamp: () => Math.floor(Date.now() / 1000) - 5,
expectedCalls: 1,
},
{
name: "delivery coordinator skips stale append messages",
id: "stale-1",
body: "old history sync",
remoteJid: "120363@g.us",
type: "append" as const,
timestamp: () => Math.floor(Date.now() / 1000) - 300,
expectedCalls: 0,
},
{
name: "delivery coordinator skips append messages with non-finite timestamps",
id: "nan-1",
body: "bad timestamp",
remoteJid: "120363@g.us",
type: "append" as const,
timestamp: () => Number.NaN,
expectedCalls: 0,
},
{
name: "delivery coordinator skips append messages with non-decimal timestamps",
id: "hex-1",
body: "hex timestamp",
remoteJid: "120363@g.us",
type: "append" as const,
timestamp: () => {
const recent = Math.floor(Date.now() / 1000) - 5;
return `0x${recent.toString(16)}`;
},
expectedCalls: 0,
},
{
name: "delivery coordinator handles Long-like protobuf timestamps",
id: "long-1",
body: "long timestamp",
remoteJid: "120363@g.us",
type: "append" as const,
timestamp: () => {
const recent = Math.floor(Date.now() / 1000) - 5;
return { low: recent, high: 0, unsigned: true, valueOf: () => recent };
},
expectedCalls: 1,
},
{
name: "delivery coordinator always processes notify messages",
id: "notify-1",
body: "normal message",
remoteJid: "999@s.whatsapp.net",
type: "notify" as const,
timestamp: () => Math.floor(Date.now() / 1000) - 86_400,
expectedCalls: 1,
},
])("$name", async ({ id, body, remoteJid, type, timestamp, expectedCalls }) => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage);
emitUpsert(sock, { id, body, remoteJid, type, timestamp: timestamp() });
if (expectedCalls === 1) {
await waitForMessageCalls(onMessage, 1);
} else {
await settleInboundWork();
}
expect(onMessage).toHaveBeenCalledTimes(expectedCalls);
await listener.close();
});
it("delivery coordinator limits reconnect catch-up appends by dedupe age", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage, {
appendReplyWindow: {
afterMs: Date.now() - 30 * 60_000,
untilMs: Date.now() + 30 * 60_000,
maxAgeMs: 20 * 60_000,
},
});
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "catch-up-1", fromMe: false, remoteJid: "999@s.whatsapp.net" },
message: { conversation: "missed while reconnecting" },
messageTimestamp: Math.floor(Date.now() / 1000) - 15 * 60,
pushName: "Reconnect Tester",
},
],
});
await waitForMessageCalls(onMessage, 1);
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "catch-up-old", fromMe: false, remoteJid: "999@s.whatsapp.net" },
message: { conversation: "before the recovery window" },
messageTimestamp: Math.floor(Date.now() / 1000) - 25 * 60,
pushName: "Reconnect Tester",
},
],
});
await settleInboundWork();
expect(onMessage).toHaveBeenCalledTimes(1);
await listener.close();
});
it("delivery coordinator preserves fresh appends after catch-up expires", async () => {
const onMessage = vi.fn(async () => {});
const { listener, sock } = await startInboxMonitor(onMessage, {
appendReplyWindow: {
afterMs: Date.now() - 30 * 60_000,
untilMs: Date.now() - 1,
maxAgeMs: 20 * 60_000,
},
});
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "catch-up-late", fromMe: false, remoteJid: "999@s.whatsapp.net" },
message: { conversation: "arrived after recovery" },
messageTimestamp: Math.floor(Date.now() / 1000) - 5 * 60,
pushName: "Reconnect Tester",
},
],
});
await settleInboundWork();
expect(onMessage).not.toHaveBeenCalled();
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: { id: "fresh-after-catch-up", fromMe: false, remoteJid: "999@s.whatsapp.net" },
message: { conversation: "fresh after recovery" },
messageTimestamp: Math.floor(Date.now() / 1000),
pushName: "Reconnect Tester",
},
],
});
await waitForMessageCalls(onMessage, 1);
expect(onMessage).toHaveBeenCalledTimes(1);
await listener.close();
});
it("delivery coordinator processes distinct catch-up messages at the boundary", async () => {
// Baileys timestamps use whole seconds. Freeze this inclusive boundary so
// async monitor startup cannot age the fixture beyond maxAgeMs.
const nowMs = 1_700_000_000_000;
const dateNow = vi.spyOn(Date, "now").mockReturnValue(nowMs);
try {
const onMessage = vi.fn(async () => {});
const boundarySeconds = nowMs / 1000 - 20 * 60;
const { listener, sock } = await startInboxMonitor(onMessage, {
appendReplyWindow: {
afterMs: boundarySeconds * 1000,
untilMs: nowMs + 30 * 60_000,
maxAgeMs: 20 * 60_000,
},
});
try {
sock.ev.emit("messages.upsert", {
type: "append",
messages: [
{
key: {
id: "catch-up-same-second",
fromMe: false,
remoteJid: "999@s.whatsapp.net",
},
message: { conversation: "same second, different message" },
messageTimestamp: boundarySeconds,
pushName: "Reconnect Tester",
},
],
});
await waitForMessageCalls(onMessage, 1);
expect(onMessage).toHaveBeenCalledTimes(1);
} finally {
await listener.close();
}
} finally {
dateNow.mockRestore();
}
});
});
@@ -1,6 +0,0 @@
// Whatsapp tests cover monitor inbox.behavior plugin behavior.
import "./monitor-inbox.allows-messages-from-senders-allowfrom-list.test-support.js";
import "./monitor-inbox.append-upsert.test-support.js";
import "./monitor-inbox.blocks-messages-from-unauthorized-senders-not-allowfrom.test-support.js";
import "./monitor-inbox.captures-media-path-image-messages.test-support.js";
import "./monitor-inbox.streams-inbound-messages.test-support.js";
@@ -0,0 +1,2 @@
// WhatsApp monitor inbox delivery and lifecycle behavior.
import "./monitor-inbox.streams-inbound-messages.test-support.js";
@@ -1,5 +1,4 @@
// Whatsapp plugin module implements monitor inbox.captures media path image messages support behavior.
import "./monitor-inbox.test-harness.js";
// WhatsApp monitor inbox media and session behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_ACCOUNT_ID,
@@ -1,5 +1,4 @@
// Whatsapp plugin module implements monitor inbox.blocks messages from unauthorized senders not allowfrom support behavior.
import "./monitor-inbox.test-harness.js";
// WhatsApp monitor inbox policy behavior.
import { describe, expect, it, vi } from "vitest";
import type { WebInboundMessage } from "./inbound/types.js";
import {
@@ -252,179 +251,133 @@ describe("web monitor inbox", () => {
await listener.close();
});
it("lets group messages through even when sender not in allowFrom", async () => {
const { onMessage, listener, sock } = await startWebInboxMonitor({
config: {
channels: { whatsapp: { allowFrom: ["+1234"], groupPolicy: "open" } },
messages: DEFAULT_MESSAGES_CFG,
},
});
sock.ev.emit(
"messages.upsert",
createNotifyUpsert(
createGroupMessage({
id: "grp3",
participant: "999@s.whatsapp.net",
conversation: "unauthorized group message",
}),
),
);
await settleInboundWork();
expect(onMessage).toHaveBeenCalledTimes(1);
const payload = firstInboundPayload(onMessage);
expect(payload.admission?.conversation.kind).toBe("group");
expect(payload.platform.senderE164).toBe("+999");
await listener.close();
});
it("blocks all group messages when groupPolicy is 'disabled'", async () => {
const { onMessage, listener, sock } = await startWebInboxMonitor({
config: {
channels: { whatsapp: { allowFrom: ["+1234"], groupPolicy: "disabled" } },
messages: TIMESTAMP_OFF_MESSAGES_CFG,
},
});
sock.ev.emit(
"messages.upsert",
createNotifyUpsert(
createGroupMessage({
id: "grp-disabled",
participant: "999@s.whatsapp.net",
conversation: "group message should be blocked",
}),
),
);
await settleInboundWork();
// Should NOT call onMessage because groupPolicy is disabled
expect(onMessage).not.toHaveBeenCalled();
await listener.close();
});
it("blocks group messages from senders not in groupAllowFrom when groupPolicy is 'allowlist'", async () => {
const { onMessage, listener, sock } = await startWebInboxMonitor({
config: {
channels: {
whatsapp: {
groupAllowFrom: ["+1234"], // Does not include +999
groupPolicy: "allowlist",
it.each([
{
name: "lets group messages through even when sender not in allowFrom",
id: "grp3",
groupPolicy: "open",
messages: DEFAULT_MESSAGES_CFG,
allowFrom: ["+1234"],
groupAllowFrom: undefined,
participant: "999@s.whatsapp.net",
remoteJid: undefined,
conversation: "unauthorized group message",
expectedCalls: 1,
expectedSender: "+999",
},
{
name: "blocks all group messages when groupPolicy is 'disabled'",
id: "grp-disabled",
groupPolicy: "disabled",
messages: TIMESTAMP_OFF_MESSAGES_CFG,
allowFrom: ["+1234"],
groupAllowFrom: undefined,
participant: "999@s.whatsapp.net",
remoteJid: undefined,
conversation: "group message should be blocked",
expectedCalls: 0,
expectedSender: undefined,
},
{
name: "blocks group messages from senders not in groupAllowFrom when groupPolicy is 'allowlist'",
id: "grp-allowlist-blocked",
groupPolicy: "allowlist",
messages: TIMESTAMP_OFF_MESSAGES_CFG,
allowFrom: undefined,
groupAllowFrom: ["+1234"],
participant: "999@s.whatsapp.net",
remoteJid: undefined,
conversation: "unauthorized group sender",
expectedCalls: 0,
expectedSender: undefined,
},
{
name: "allows group messages from senders in groupAllowFrom when groupPolicy is 'allowlist'",
id: "grp-allowlist-allowed",
groupPolicy: "allowlist",
messages: TIMESTAMP_OFF_MESSAGES_CFG,
allowFrom: undefined,
groupAllowFrom: ["+15551234567"],
participant: "15551234567@s.whatsapp.net",
remoteJid: undefined,
conversation: "authorized group sender",
expectedCalls: 1,
expectedSender: "+15551234567",
},
{
name: "allows all group senders with wildcard in groupPolicy allowlist",
id: "grp-wildcard-test",
groupPolicy: "allowlist",
messages: TIMESTAMP_OFF_MESSAGES_CFG,
allowFrom: undefined,
groupAllowFrom: ["*"],
participant: "9999999999@s.whatsapp.net",
remoteJid: "22222@g.us",
conversation: "wildcard group sender",
expectedCalls: 1,
expectedSender: undefined,
},
{
name: "blocks group messages when groupPolicy allowlist has no groupAllowFrom",
id: "grp-allowlist-empty",
groupPolicy: "allowlist",
messages: TIMESTAMP_OFF_MESSAGES_CFG,
allowFrom: undefined,
groupAllowFrom: undefined,
participant: "999@s.whatsapp.net",
remoteJid: undefined,
conversation: "blocked by empty allowlist",
expectedCalls: 0,
expectedSender: undefined,
},
] as const)(
"$name",
async ({
id,
groupPolicy,
messages,
allowFrom,
groupAllowFrom,
participant,
remoteJid,
conversation,
expectedCalls,
expectedSender,
}) => {
const { onMessage, listener, sock } = await startWebInboxMonitor({
config: {
channels: {
whatsapp: {
groupPolicy,
...(allowFrom ? { allowFrom: [...allowFrom] } : {}),
...(groupAllowFrom ? { groupAllowFrom: [...groupAllowFrom] } : {}),
},
},
messages,
},
messages: TIMESTAMP_OFF_MESSAGES_CFG,
},
});
sock.ev.emit(
"messages.upsert",
createNotifyUpsert(
createGroupMessage({
id: "grp-allowlist-blocked",
participant: "999@s.whatsapp.net",
conversation: "unauthorized group sender",
}),
),
);
await settleInboundWork();
});
sock.ev.emit(
"messages.upsert",
createNotifyUpsert(
createGroupMessage({
id,
...(remoteJid ? { remoteJid } : {}),
participant,
conversation,
}),
),
);
await settleInboundWork();
// Should NOT call onMessage because sender +999 not in groupAllowFrom
expect(onMessage).not.toHaveBeenCalled();
await listener.close();
});
it("allows group messages from senders in groupAllowFrom when groupPolicy is 'allowlist'", async () => {
const { onMessage, listener, sock } = await startWebInboxMonitor({
config: {
channels: {
whatsapp: {
groupAllowFrom: ["+15551234567"], // Includes the sender
groupPolicy: "allowlist",
},
},
messages: TIMESTAMP_OFF_MESSAGES_CFG,
},
});
sock.ev.emit(
"messages.upsert",
createNotifyUpsert(
createGroupMessage({
id: "grp-allowlist-allowed",
participant: "15551234567@s.whatsapp.net",
conversation: "authorized group sender",
}),
),
);
await settleInboundWork();
// Should call onMessage because sender is in groupAllowFrom
expect(onMessage).toHaveBeenCalledTimes(1);
const payload = firstInboundPayload(onMessage);
expect(payload.admission?.conversation.kind).toBe("group");
expect(payload.platform.senderE164).toBe("+15551234567");
await listener.close();
});
it("allows all group senders with wildcard in groupPolicy allowlist", async () => {
const { onMessage, listener, sock } = await startWebInboxMonitor({
config: {
channels: {
whatsapp: {
groupAllowFrom: ["*"], // Wildcard allows everyone
groupPolicy: "allowlist",
},
},
messages: TIMESTAMP_OFF_MESSAGES_CFG,
},
});
sock.ev.emit(
"messages.upsert",
createNotifyUpsert(
createGroupMessage({
id: "grp-wildcard-test",
remoteJid: "22222@g.us",
participant: "9999999999@s.whatsapp.net",
conversation: "wildcard group sender",
}),
),
);
await settleInboundWork();
// Should call onMessage because wildcard allows all senders
expect(onMessage).toHaveBeenCalledTimes(1);
const payload = firstInboundPayload(onMessage);
expect(payload.admission?.conversation.kind).toBe("group");
await listener.close();
});
it("blocks group messages when groupPolicy allowlist has no groupAllowFrom", async () => {
const { onMessage, listener, sock } = await startWebInboxMonitor({
config: {
channels: {
whatsapp: {
groupPolicy: "allowlist",
},
},
messages: TIMESTAMP_OFF_MESSAGES_CFG,
},
});
sock.ev.emit(
"messages.upsert",
createNotifyUpsert(
createGroupMessage({
id: "grp-allowlist-empty",
participant: "999@s.whatsapp.net",
conversation: "blocked by empty allowlist",
}),
),
);
await settleInboundWork();
expect(onMessage).not.toHaveBeenCalled();
await listener.close();
});
expect(onMessage).toHaveBeenCalledTimes(expectedCalls);
if (expectedCalls === 1) {
const payload = firstInboundPayload(onMessage);
expect(payload.admission?.conversation.kind).toBe("group");
if (expectedSender) {
expect(payload.platform.senderE164).toBe(expectedSender);
}
}
await listener.close();
},
);
});
@@ -1,8 +1,7 @@
// Whatsapp plugin module implements monitor inbox.streams inbound messages support behavior.
// WhatsApp monitor inbox delivery and lifecycle behavior.
import fsSync from "node:fs";
import path from "node:path";
import type { GroupMetadata, WAMessageKey } from "baileys";
import "./monitor-inbox.test-harness.js";
import { defaultRuntime } from "openclaw/plugin-sdk/runtime-env";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
@@ -3,6 +3,7 @@ import { EventEmitter } from "node:events";
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import { createChannelIngressQueueForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { resetLogger, setLoggerOverride } from "openclaw/plugin-sdk/runtime-env";
import { afterEach, beforeEach, expect, vi } from "vitest";
import {
@@ -10,6 +11,7 @@ import {
resetPairingSecurityMocks,
upsertPairingRequestMock as pairingUpsertPairingRequestMock,
} from "./pairing-security.test-harness.js";
import { setWhatsAppRuntime } from "./runtime.js";
// Avoid exporting vitest mock types (TS2742 under pnpm + d.ts emit).
type AnyMockFn = any;
@@ -131,26 +133,6 @@ vi.mock("openclaw/plugin-sdk/channel-activity-runtime", async () => {
};
});
vi.mock("./runtime.js", async () => {
const { createChannelIngressQueueForTests: createChannelIngressQueue } = await Promise.resolve(
vi.importActual<typeof import("openclaw/plugin-sdk/plugin-state-test-runtime")>(
"openclaw/plugin-sdk/plugin-state-test-runtime",
),
);
return {
getWhatsAppRuntime: () => ({
state: {
resolveStateDir: pluginRuntimeMocks.stateDir,
openKeyedStore: pluginRuntimeMocks.openKeyedStore,
openChannelIngressQueue: (
options?: Omit<Parameters<typeof createChannelIngressQueue>[0], "channelId">,
) => createChannelIngressQueue({ ...options, channelId: "whatsapp" }),
},
}),
setWhatsAppRuntime: vi.fn(),
};
});
const inboundRuntimeMocks = vi.hoisted(() => {
const wrapperKeys = [
"ephemeralMessage",
@@ -391,6 +373,16 @@ export function installWebMonitorInboxUnitTestHooks(opts?: { authDir?: boolean }
vi.clearAllMocks();
channelActivityMocks.recordChannelActivity.mockClear();
pluginRuntimeMocks.reset();
setWhatsAppRuntime({
channel: {},
state: {
resolveStateDir: pluginRuntimeMocks.stateDir,
openKeyedStore: pluginRuntimeMocks.openKeyedStore,
openChannelIngressQueue: (
options?: Omit<Parameters<typeof createChannelIngressQueueForTests>[0], "channelId">,
) => createChannelIngressQueueForTests({ ...options, channelId: "whatsapp" }),
},
} as never);
sessionState.sock = createMockSock();
resetPairingSecurityMocks(DEFAULT_WEB_INBOX_CONFIG);
if (!monitorWebInbox || !resetWebInboundDedupe) {