mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
fix: keep whatsapp inbound aliases live
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user