mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
test: consolidate WhatsApp monitor suites (#117854)
* test: consolidate WhatsApp monitor suites * test: preserve WhatsApp max-lines ratchet path
This commit is contained in:
committed by
GitHub
parent
98c0d9deca
commit
dc38411ca4
+2
-2
@@ -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"];
|
||||
+2
-2
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
+43
-47
@@ -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 () => {
|
||||
|
||||
+243
@@ -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
-2
@@ -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
-2
@@ -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,
|
||||
+127
-174
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user