From de9260f8135d384dda5a09e2e425bfad76f2083e Mon Sep 17 00:00:00 2001 From: Shakker Date: Thu, 11 Jun 2026 16:35:19 +0100 Subject: [PATCH] fix: keep whatsapp inbound aliases live --- .../src/inbound/message-aliases.test.ts | 174 ++++++++++++ .../whatsapp/src/inbound/message-aliases.ts | 255 +++++++++++++++--- .../reply/dispatch-from-config.test.ts | 34 +++ 3 files changed, 425 insertions(+), 38 deletions(-) create mode 100644 extensions/whatsapp/src/inbound/message-aliases.test.ts diff --git a/extensions/whatsapp/src/inbound/message-aliases.test.ts b/extensions/whatsapp/src/inbound/message-aliases.test.ts new file mode 100644 index 000000000000..e59f76eff41b --- /dev/null +++ b/extensions/whatsapp/src/inbound/message-aliases.test.ts @@ -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 = {}) { + 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"); + }); +}); diff --git a/extensions/whatsapp/src/inbound/message-aliases.ts b/extensions/whatsapp/src/inbound/message-aliases.ts index ff46b38faa9c..41eaef230b95 100644 --- a/extensions/whatsapp/src/inbound/message-aliases.ts +++ b/extensions/whatsapp/src/inbound/message-aliases.ts @@ -1,4 +1,5 @@ import type { + DeprecatedWebInboundMessageFlatAliases, LegacyFlatWebInboundMessage, WebInboundCallbackMessage, WebInboundMessage, @@ -8,6 +9,10 @@ import type { } from "./types.js"; type QuoteSender = NonNullable; +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 { + return (msg.payload.media ??= {}); +} + +function setMediaField>( + msg: WebInboundCallbackMessage, + key: K, + value: NonNullable[K] | undefined, +) { + if (value === undefined && !msg.payload.media) { + return; + } + ensureMedia(msg)[key] = value; +} + +function defineDeprecatedAliasAccessors( + msg: T, + descriptors: Record, +): 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( 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 { diff --git a/src/auto-reply/reply/dispatch-from-config.test.ts b/src/auto-reply/reply/dispatch-from-config.test.ts index 74c1e58e743c..11aee2f6150d 100644 --- a/src/auto-reply/reply/dispatch-from-config.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.test.ts @@ -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(