fix: keep whatsapp inbound aliases live

This commit is contained in:
Shakker
2026-06-11 16:35:19 +01:00
committed by Shakker
parent 4d45884419
commit de9260f813
3 changed files with 425 additions and 38 deletions
@@ -0,0 +1,174 @@
// WhatsApp tests cover inbound message alias compatibility.
import { describe, expect, it, vi } from "vitest";
import {
normalizeWebInboundMessage,
withDeprecatedWebInboundMessageFlatAliases,
} from "./message-aliases.js";
import { createAcceptedWhatsAppSendResult } from "./send-result.test-helper.js";
import type { LegacyFlatWebInboundMessage, WebInboundCallbackMessage } from "./types.js";
function createCanonicalMessage(overrides: Partial<WebInboundCallbackMessage> = {}) {
return withDeprecatedWebInboundMessageFlatAliases({
event: {
id: "event-1",
timestamp: 1_700_000_000,
isBatched: false,
},
payload: {
body: "hello",
media: {
path: "/tmp/image.jpg",
type: "image/jpeg",
fileName: "image.jpg",
url: "https://example.com/image.jpg",
},
untrustedStructuredContext: [
{
label: "WhatsApp contact",
source: "whatsapp",
type: "contact",
payload: { name: "Alice" },
},
],
},
platform: {
chatJid: "123@g.us",
recipientJid: "+15550000001",
senderJid: "15550000002@s.whatsapp.net",
senderE164: "+15550000002",
senderName: "Alice",
pushName: "Alice P",
selfE164: "+15550000001",
fromMe: false,
sendComposing: vi.fn(async () => undefined),
reply: vi.fn(async () => createAcceptedWhatsAppSendResult("text", "reply-1")),
sendMedia: vi.fn(async () => createAcceptedWhatsAppSendResult("media", "media-1")),
},
from: "123@g.us",
conversationId: "123@g.us",
accountId: "default",
chatType: "group",
quote: {
id: "quote-1",
body: "quoted",
sender: {
displayName: "Bob",
jid: "15550000003@s.whatsapp.net",
e164: "+15550000003",
},
},
group: {
subject: "Test Group",
participants: ["15550000002@s.whatsapp.net"],
mentions: {
jids: ["15550000001@s.whatsapp.net"],
},
},
...overrides,
});
}
describe("WhatsApp inbound flat aliases", () => {
it("keeps deprecated flat aliases live against canonical contexts", async () => {
const msg = createCanonicalMessage();
const nextReply = vi.fn(async () => createAcceptedWhatsAppSendResult("text", "reply-2"));
expect(msg.body).toBe("hello");
msg.payload.body = "nested body";
expect(msg.body).toBe("nested body");
msg.body = "flat body";
expect(msg.payload.body).toBe("flat body");
msg.platform.chatJid = "456@g.us";
expect(msg.chatId).toBe("456@g.us");
msg.chatId = "789@g.us";
expect(msg.platform.chatJid).toBe("789@g.us");
msg.payload.media = { path: "/tmp/next.jpg", type: "image/png" };
expect(msg.mediaPath).toBe("/tmp/next.jpg");
expect(msg.mediaType).toBe("image/png");
msg.mediaFileName = "next.jpg";
msg.mediaUrl = "https://example.com/next.jpg";
expect(msg.payload.media).toMatchObject({
fileName: "next.jpg",
url: "https://example.com/next.jpg",
});
msg.group!.mentions!.jids = ["first@s.whatsapp.net"];
expect(msg.mentions).toEqual(["first@s.whatsapp.net"]);
msg.mentionedJids = ["second@s.whatsapp.net"];
expect(msg.group?.mentions?.jids).toEqual(["second@s.whatsapp.net"]);
expect(msg.mentions).toEqual(["second@s.whatsapp.net"]);
msg.reply = nextReply;
expect(msg.platform.reply).toBe(nextReply);
await msg.platform.reply("ok");
expect(nextReply).toHaveBeenCalledWith("ok");
expect(Object.keys(msg)).toContain("body");
expect(Object.keys(msg)).toContain("chatId");
});
it("normalizes legacy flat messages into canonical contexts with live aliases", () => {
const legacyReply = vi.fn(async () => createAcceptedWhatsAppSendResult("text", "reply-legacy"));
const legacy: LegacyFlatWebInboundMessage = {
id: "legacy-1",
timestamp: 1_700_000_123,
from: "+15550000002",
conversationId: "+15550000002",
accountId: "default",
chatType: "direct",
to: "+15550000001",
body: "legacy body",
chatId: "15550000002@s.whatsapp.net",
replyToId: "quote-legacy",
replyToBody: "legacy quoted",
replyToSender: "Legacy Sender",
replyToSenderJid: "15550000003@s.whatsapp.net",
groupSubject: "Legacy Group",
mentionedJids: ["15550000001@s.whatsapp.net"],
sendComposing: vi.fn(async () => undefined),
reply: legacyReply,
sendMedia: vi.fn(async () => createAcceptedWhatsAppSendResult("media", "media-legacy")),
mediaPath: "/tmp/legacy.jpg",
mediaType: "image/jpeg",
isBatched: true,
};
const normalized = normalizeWebInboundMessage(legacy);
expect(normalized.event).toMatchObject({
id: "legacy-1",
timestamp: 1_700_000_123,
isBatched: true,
});
expect(normalized.payload.body).toBe("legacy body");
expect(normalized.payload.media).toMatchObject({
path: "/tmp/legacy.jpg",
type: "image/jpeg",
});
expect(normalized.platform).toMatchObject({
chatJid: "15550000002@s.whatsapp.net",
recipientJid: "+15550000001",
});
expect(normalized.quote).toMatchObject({
id: "quote-legacy",
body: "legacy quoted",
sender: {
displayName: "Legacy Sender",
jid: "15550000003@s.whatsapp.net",
},
});
expect(normalized.group).toMatchObject({
subject: "Legacy Group",
mentions: {
jids: ["15550000001@s.whatsapp.net"],
},
});
normalized.payload.body = "canonical update";
expect(normalized.body).toBe("canonical update");
normalized.replyToSender = "Updated Sender";
expect(normalized.quote?.sender?.displayName).toBe("Updated Sender");
});
});
@@ -1,4 +1,5 @@
import type {
DeprecatedWebInboundMessageFlatAliases,
LegacyFlatWebInboundMessage,
WebInboundCallbackMessage,
WebInboundMessage,
@@ -8,6 +9,10 @@ import type {
} from "./types.js";
type QuoteSender = NonNullable<WhatsAppInboundQuote["sender"]>;
type AliasDescriptor = {
get: () => unknown;
set: (value: unknown) => void;
};
function normalizeQuoteSender(sender: QuoteSender | undefined): QuoteSender | undefined {
if (!sender?.displayName && !sender?.jid && !sender?.e164) {
@@ -65,48 +70,222 @@ function buildGroupFromFlatAliases(
};
}
function ensureQuote(msg: WebInboundCallbackMessage): WhatsAppInboundQuote {
return (msg.quote ??= {});
}
function ensureQuoteSender(msg: WebInboundCallbackMessage): QuoteSender {
const quote = ensureQuote(msg);
return (quote.sender ??= {});
}
function ensureGroup(msg: WebInboundCallbackMessage): WhatsAppInboundGroupContext {
return (msg.group ??= {});
}
function ensureGroupMentions(msg: WebInboundCallbackMessage): { jids?: string[]; text?: string[] } {
const group = ensureGroup(msg);
return (group.mentions ??= {});
}
function ensureMedia(
msg: WebInboundCallbackMessage,
): NonNullable<WebInboundCallbackMessage["payload"]["media"]> {
return (msg.payload.media ??= {});
}
function setMediaField<K extends keyof NonNullable<WebInboundCallbackMessage["payload"]["media"]>>(
msg: WebInboundCallbackMessage,
key: K,
value: NonNullable<WebInboundCallbackMessage["payload"]["media"]>[K] | undefined,
) {
if (value === undefined && !msg.payload.media) {
return;
}
ensureMedia(msg)[key] = value;
}
function defineDeprecatedAliasAccessors<T extends WebInboundCallbackMessage>(
msg: T,
descriptors: Record<keyof DeprecatedWebInboundMessageFlatAliases, AliasDescriptor>,
): T & WebInboundMessage {
Object.defineProperties(
msg,
Object.fromEntries(
Object.entries(descriptors).map(([key, descriptor]) => [
key,
{
configurable: true,
enumerable: true,
get: descriptor.get,
set: descriptor.set,
},
]),
),
);
return msg as T & WebInboundMessage;
}
export function withDeprecatedWebInboundMessageFlatAliases<T extends WebInboundCallbackMessage>(
msg: T,
): T & WebInboundMessage {
// Keep the shipped callback shape alive while nested contexts remain canonical.
return {
...msg,
id: msg.event.id,
to: msg.platform.recipientJid,
body: msg.payload.body,
pushName: msg.platform.pushName,
timestamp: msg.event.timestamp,
chatId: msg.platform.chatJid,
sender: msg.platform.sender,
senderJid: msg.platform.senderJid,
senderE164: msg.platform.senderE164,
senderName: msg.platform.senderName,
replyTo: msg.quote?.context,
replyToId: msg.quote?.id ?? msg.quote?.context?.id,
replyToBody: msg.quote?.body ?? msg.quote?.context?.body,
replyToSender: msg.quote?.context?.sender?.label ?? msg.quote?.sender?.displayName,
replyToSenderJid: msg.quote?.context?.sender?.jid ?? msg.quote?.sender?.jid,
replyToSenderE164: msg.quote?.context?.sender?.e164 ?? msg.quote?.sender?.e164,
groupSubject: msg.group?.subject,
groupParticipants: msg.group?.participants,
mentions: msg.group?.mentions?.jids,
mentionedJids: msg.group?.mentions?.jids,
self: msg.platform.self,
selfJid: msg.platform.selfJid,
selfLid: msg.platform.selfLid,
selfE164: msg.platform.selfE164,
fromMe: msg.platform.fromMe,
location: msg.payload.location,
sendComposing: msg.platform.sendComposing,
reply: msg.platform.reply,
sendMedia: msg.platform.sendMedia,
mediaPath: msg.payload.media?.path,
mediaType: msg.payload.media?.type,
mediaFileName: msg.payload.media?.fileName,
mediaUrl: msg.payload.media?.url,
untrustedStructuredContext: msg.payload.untrustedStructuredContext,
isBatched: msg.event.isBatched,
};
return defineDeprecatedAliasAccessors(msg, {
id: { get: () => msg.event.id, set: (value) => (msg.event.id = value as string | undefined) },
to: {
get: () => msg.platform.recipientJid,
set: (value) => (msg.platform.recipientJid = value as string),
},
body: { get: () => msg.payload.body, set: (value) => (msg.payload.body = value as string) },
pushName: {
get: () => msg.platform.pushName,
set: (value) => (msg.platform.pushName = value as string | undefined),
},
timestamp: {
get: () => msg.event.timestamp,
set: (value) => (msg.event.timestamp = value as number | undefined),
},
chatId: {
get: () => msg.platform.chatJid,
set: (value) => (msg.platform.chatJid = value as string),
},
sender: {
get: () => msg.platform.sender,
set: (value) => (msg.platform.sender = value as typeof msg.platform.sender),
},
senderJid: {
get: () => msg.platform.senderJid,
set: (value) => (msg.platform.senderJid = value as string | undefined),
},
senderE164: {
get: () => msg.platform.senderE164,
set: (value) => (msg.platform.senderE164 = value as string | undefined),
},
senderName: {
get: () => msg.platform.senderName,
set: (value) => (msg.platform.senderName = value as string | undefined),
},
replyTo: {
get: () => msg.quote?.context,
set: (value) => (ensureQuote(msg).context = value as WhatsAppInboundQuote["context"]),
},
replyToId: {
get: () => msg.quote?.id ?? msg.quote?.context?.id,
set: (value) => (ensureQuote(msg).id = value as string | undefined),
},
replyToBody: {
get: () => msg.quote?.body ?? msg.quote?.context?.body,
set: (value) => (ensureQuote(msg).body = value as string | undefined),
},
replyToSender: {
get: () => msg.quote?.context?.sender?.label ?? msg.quote?.sender?.displayName,
set: (value) => {
const sender = ensureQuoteSender(msg);
sender.displayName = value as string | undefined;
if (msg.quote?.context?.sender) {
msg.quote.context.sender.label = value as string | undefined;
}
},
},
replyToSenderJid: {
get: () => msg.quote?.context?.sender?.jid ?? msg.quote?.sender?.jid,
set: (value) => {
const jid = value as string | undefined;
ensureQuoteSender(msg).jid = jid;
if (msg.quote?.context?.sender) {
msg.quote.context.sender.jid = jid;
}
},
},
replyToSenderE164: {
get: () => msg.quote?.context?.sender?.e164 ?? msg.quote?.sender?.e164,
set: (value) => {
const e164 = value as string | undefined;
ensureQuoteSender(msg).e164 = e164;
if (msg.quote?.context?.sender) {
msg.quote.context.sender.e164 = e164;
}
},
},
groupSubject: {
get: () => msg.group?.subject,
set: (value) => (ensureGroup(msg).subject = value as string | undefined),
},
groupParticipants: {
get: () => msg.group?.participants,
set: (value) => (ensureGroup(msg).participants = value as string[] | undefined),
},
mentions: {
get: () => msg.group?.mentions?.jids,
set: (value) => (ensureGroupMentions(msg).jids = value as string[] | undefined),
},
mentionedJids: {
get: () => msg.group?.mentions?.jids,
set: (value) => (ensureGroupMentions(msg).jids = value as string[] | undefined),
},
self: {
get: () => msg.platform.self,
set: (value) => (msg.platform.self = value as typeof msg.platform.self),
},
selfJid: {
get: () => msg.platform.selfJid,
set: (value) => (msg.platform.selfJid = value as string | null | undefined),
},
selfLid: {
get: () => msg.platform.selfLid,
set: (value) => (msg.platform.selfLid = value as string | null | undefined),
},
selfE164: {
get: () => msg.platform.selfE164,
set: (value) => (msg.platform.selfE164 = value as string | null | undefined),
},
fromMe: {
get: () => msg.platform.fromMe,
set: (value) => (msg.platform.fromMe = value as boolean | undefined),
},
location: {
get: () => msg.payload.location,
set: (value) => (msg.payload.location = value as typeof msg.payload.location),
},
sendComposing: {
get: () => msg.platform.sendComposing,
set: (value) => (msg.platform.sendComposing = value as typeof msg.platform.sendComposing),
},
reply: {
get: () => msg.platform.reply,
set: (value) => (msg.platform.reply = value as typeof msg.platform.reply),
},
sendMedia: {
get: () => msg.platform.sendMedia,
set: (value) => (msg.platform.sendMedia = value as typeof msg.platform.sendMedia),
},
mediaPath: {
get: () => msg.payload.media?.path,
set: (value) => setMediaField(msg, "path", value as string | undefined),
},
mediaType: {
get: () => msg.payload.media?.type,
set: (value) => setMediaField(msg, "type", value as string | undefined),
},
mediaFileName: {
get: () => msg.payload.media?.fileName,
set: (value) => setMediaField(msg, "fileName", value as string | undefined),
},
mediaUrl: {
get: () => msg.payload.media?.url,
set: (value) => setMediaField(msg, "url", value as string | undefined),
},
untrustedStructuredContext: {
get: () => msg.payload.untrustedStructuredContext,
set: (value) =>
(msg.payload.untrustedStructuredContext =
value as typeof msg.payload.untrustedStructuredContext),
},
isBatched: {
get: () => msg.event.isBatched,
set: (value) => (msg.event.isBatched = value as boolean | undefined),
},
});
}
function normalizeLegacyFlatWebInboundMessage(msg: LegacyFlatWebInboundMessage): WebInboundMessage {
@@ -5227,6 +5227,40 @@ describe("dispatchReplyFromConfig", () => {
expect(hookContext?.conversationId).toBe("telegram:999");
});
it("does not emit shared message_received hooks when the channel emitted them itself", async () => {
setNoAbort();
hookMocks.runner.hasHooks.mockImplementation(
((hookName?: string) => hookName === "message_received") as () => boolean,
);
const cfg = emptyConfig;
const dispatcher = createDispatcher();
const ctx = buildTestCtx({
Provider: "whatsapp",
Surface: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "whatsapp:+15555550123",
CommandBody: "hello",
RawBody: "hello",
Body: "hello",
MessageSid: "wa-msg-1",
SessionKey: "agent:main:whatsapp:+15555550123",
SuppressMessageReceivedHooks: true,
});
const replyResolver = vi.fn(async () => ({ text: "hi" }) satisfies ReplyPayload);
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
expect(replyResolver).toHaveBeenCalledTimes(1);
expect(hookMocks.runner.runMessageReceived).not.toHaveBeenCalled();
expect(internalHookMocks.createInternalHookEvent).not.toHaveBeenCalledWith(
"message",
"received",
expect.anything(),
expect.anything(),
);
expect(internalHookMocks.triggerInternalHook).not.toHaveBeenCalled();
});
it("does not broadcast inbound claims without a core-owned plugin binding", async () => {
setNoAbort();
hookMocks.runner.hasHooks.mockImplementation(