fix(whatsapp): also recognize button/list/interactive responses as content (#73797)

Greptile review on the original PR flagged that the four extractors used in
`hasInboundUserContent` (`extractText`, `extractMediaPlaceholder`,
`extractContactContext`, `extractLocationData`) do not surface
`buttonsResponseMessage`, `listResponseMessage`, `templateButtonReplyMessage`,
or `interactiveResponseMessage`. The gate would have silently dropped real
user button/list selections as if they were receipts.

Add a `hasInteractiveResponseContent(message)` helper that checks for the
four reply-message keys directly, and call it from `hasInboundUserContent`
both at the root and after walking `buildMessageChain` (so wrapped
ephemeral/viewOnce envelopes still surface their interactive responses).

5 new test cases cover all four reply types plus the wrapped-ephemeral path.

Sign-Off: hclsys
This commit is contained in:
HCL
2026-04-29 05:46:07 +08:00
committed by Marcus Castro
parent 53bd5d6bd2
commit 236c04dd71
2 changed files with 89 additions and 3 deletions

View File

@@ -169,6 +169,65 @@ describe("hasInboundUserContent", () => {
).toBe(true);
});
it("returns true for buttons response (user button click)", () => {
expect(
hasInboundUserContent({
buttonsResponseMessage: {
selectedButtonId: "yes",
selectedDisplayText: "Yes",
},
} as proto.IMessage),
).toBe(true);
});
it("returns true for list response (user list selection)", () => {
expect(
hasInboundUserContent({
listResponseMessage: {
title: "Option A",
singleSelectReply: { selectedRowId: "a" },
} as unknown as proto.Message.IListResponseMessage,
} as proto.IMessage),
).toBe(true);
});
it("returns true for template button reply", () => {
expect(
hasInboundUserContent({
templateButtonReplyMessage: {
selectedId: "btn-1",
selectedDisplayText: "Click",
} as unknown as proto.Message.ITemplateButtonReplyMessage,
} as proto.IMessage),
).toBe(true);
});
it("returns true for interactive response", () => {
expect(
hasInboundUserContent({
interactiveResponseMessage: {
body: { text: "x" },
nativeFlowResponseMessage: { name: "n", paramsJson: "{}" },
} as unknown as proto.Message.IInteractiveResponseMessage,
} as proto.IMessage),
).toBe(true);
});
it("returns true for buttons response wrapped in ephemeralMessage (regression for #73797 + greptile review)", () => {
expect(
hasInboundUserContent({
ephemeralMessage: {
message: {
buttonsResponseMessage: {
selectedButtonId: "ok",
selectedDisplayText: "OK",
},
},
},
} as proto.IMessage),
).toBe(true);
});
it("returns false for undefined message (regression for #73797)", () => {
expect(hasInboundUserContent(undefined)).toBe(false);
});

View File

@@ -439,11 +439,28 @@ export function describeReplyContext(
};
}
function hasInteractiveResponseContent(message: proto.IMessage | undefined): boolean {
if (!message) {
return false;
}
// Button/list/template/interactive selections that the existing four
// extractors do not cover. Treat any presence of these keys as user
// content — Baileys never delivers these as receipts or protocol
// envelopes, only as explicit user choices.
return Boolean(
message.buttonsResponseMessage ||
message.listResponseMessage ||
message.templateButtonReplyMessage ||
message.interactiveResponseMessage,
);
}
/**
* Fast O(1) check that a Baileys message carries user-visible inbound content
* (text, media, contact, location). Returns false for protocol/receipt/typing
* notifications that arrive on the same `messages.upsert` stream as real
* messages but should not trigger pairing access-control side effects.
* (text, media, contact, location, button/list selection). Returns false for
* protocol/receipt/typing notifications that arrive on the same
* `messages.upsert` stream as real messages but should not trigger pairing
* access-control side effects.
*/
export function hasInboundUserContent(rawMessage: proto.IMessage | undefined): boolean {
if (!rawMessage) {
@@ -461,5 +478,15 @@ export function hasInboundUserContent(rawMessage: proto.IMessage | undefined): b
if (extractLocationData(rawMessage)) {
return true;
}
if (hasInteractiveResponseContent(rawMessage)) {
return true;
}
// Walk wrappers (ephemeral, viewOnce, etc.) — interactive responses
// can arrive nested.
for (const candidate of buildMessageChain(rawMessage)) {
if (candidate !== rawMessage && hasInteractiveResponseContent(candidate)) {
return true;
}
}
return false;
}