fix(whatsapp): gate pairing access-control on extractable inbound user content (#73797)

`checkInboundAccessControl` is currently called for every `messages.upsert`
notify entry, including receipts, typing indicators, presence updates, and
protocol messages that arrive on the same Baileys stream as real inbound
messages. With `dmPolicy: pairing`, this lets the gateway send a pairing
verification reply to a peer who never actually typed anything — e.g. when
Master sends an outbound message to a new JID and the receipt round-trip
arrives before the recipient ever replies.

Add a fast O(1) `hasInboundUserContent(message)` helper in
`extensions/whatsapp/src/inbound/extract.ts` that returns true iff any of
the existing extractors would surface user-visible content
(`extractText`, `extractMediaPlaceholder`, `extractContactContext`,
`extractLocationData`). Call it at the top of `normalizeInboundMessage`
right after the existing `fromMe` recent-outbound-echo guard, before
`checkInboundAccessControl`. Non-content events bail out cleanly with no
pairing side effects, no read-receipt, no enqueue.

Existing safeguards (recent-outbound echo skip, status/broadcast filter)
stay intact. All four content extractors are pure object-tree walks with
no I/O, so the gate adds only microseconds per upsert event.

Sign-Off: hclsys
This commit is contained in:
HCL
2026-04-29 05:29:05 +08:00
committed by Marcus Castro
parent 381c2e1d1a
commit 53bd5d6bd2
4 changed files with 150 additions and 1 deletions

View File

@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
- Telegram/exec approvals: stop treating general Telegram chat allowlists and `defaultTo` routes as native exec approvers; Telegram now uses explicit `execApprovals.approvers` or owner identity from `commands.ownerAllowFrom`, matching the first-pairing owner bootstrap path. Thanks @pashpashpash.
- Chat commands: route sensitive group `/diagnostics` and `/export-trajectory` approvals and results to a private owner route, preferring same-surface DMs before falling back to the first configured owner route, so Discord group invocations can land in Telegram when that is the primary owner interface. Thanks @pashpashpash.
- Channels/WhatsApp: gate `dmPolicy: pairing` access-control side effects on whether a Baileys `messages.upsert` event actually carries inbound text/media/contact/location content, so receipts, typing indicators, presence updates, and protocol messages no longer trigger an unsolicited pairing verification reply on a peer who never typed. Fixes #73797. Thanks @hbmasters.
- Plugin SDK/Discord: restore a deprecated `openclaw/plugin-sdk/discord` compatibility facade and the legacy compat group-policy warning export for the published `@openclaw/discord@2026.3.13` package, covering its config, account, directory, status, and thread-binding imports while keeping new plugins on generic SDK subpaths. Fixes #73685; supersedes #73703. Thanks @rderickson9 and @SymbolStar.
- Channels/Discord: suppress duplicate gateway monitors when multiple enabled accounts resolve to the same bot token, preferring config tokens over default env fallback and reporting skipped duplicates as disabled. Supersedes #73608. Thanks @kagura-agent.
- Control UI/Talk: decode Google Live binary WebSocket JSON frames and stop queued browser audio on interruption or shutdown, so browser Talk leaves `Connecting Talk...` and barge-in no longer plays stale audio. Fixes #73601 and #73460; supersedes #73466. Thanks @Spolen23 and @WadydX.

View File

@@ -1,6 +1,6 @@
import type { proto } from "@whiskeysockets/baileys";
import { describe, expect, it } from "vitest";
import { extractMentionedJids } from "./extract.js";
import { extractMentionedJids, hasInboundUserContent } from "./extract.js";
describe("extractMentionedJids", () => {
const botJid = "5511999999999@s.whatsapp.net";
@@ -101,3 +101,113 @@ describe("extractMentionedJids", () => {
expect(extractMentionedJids(message)).toEqual([botJid]);
});
});
describe("hasInboundUserContent", () => {
it("returns true for plain text conversation", () => {
expect(hasInboundUserContent({ conversation: "hello" })).toBe(true);
});
it("returns true for extendedTextMessage", () => {
expect(
hasInboundUserContent({ extendedTextMessage: { text: "hello" } } as proto.IMessage),
).toBe(true);
});
it("returns true for image message", () => {
expect(
hasInboundUserContent({ imageMessage: { mimetype: "image/png" } } as proto.IMessage),
).toBe(true);
});
it("returns true for video message", () => {
expect(
hasInboundUserContent({ videoMessage: { mimetype: "video/mp4" } } as proto.IMessage),
).toBe(true);
});
it("returns true for audio message", () => {
expect(
hasInboundUserContent({ audioMessage: { mimetype: "audio/ogg" } } as proto.IMessage),
).toBe(true);
});
it("returns true for document message", () => {
expect(
hasInboundUserContent({
documentMessage: { fileName: "x.pdf" },
} as proto.IMessage),
).toBe(true);
});
it("returns true for sticker message", () => {
expect(
hasInboundUserContent({ stickerMessage: { mimetype: "image/webp" } } as proto.IMessage),
).toBe(true);
});
it("returns true for location message with valid coords", () => {
expect(
hasInboundUserContent({
locationMessage: { degreesLatitude: 1, degreesLongitude: 2 },
} as proto.IMessage),
).toBe(true);
});
it("returns true for live location message with valid coords", () => {
expect(
hasInboundUserContent({
liveLocationMessage: { degreesLatitude: 1, degreesLongitude: 2 },
} as proto.IMessage),
).toBe(true);
});
it("returns true for contact message", () => {
expect(
hasInboundUserContent({
contactMessage: { displayName: "Alice", vcard: "BEGIN:VCARD\nEND:VCARD" },
} as proto.IMessage),
).toBe(true);
});
it("returns false for undefined message (regression for #73797)", () => {
expect(hasInboundUserContent(undefined)).toBe(false);
});
it("returns false for empty message object (no content keys)", () => {
expect(hasInboundUserContent({} as proto.IMessage)).toBe(false);
});
it("returns false for protocol message envelope without inner content (regression for #73797)", () => {
expect(
hasInboundUserContent({
protocolMessage: {
type: 0,
} as unknown as proto.Message.IProtocolMessage,
} as proto.IMessage),
).toBe(false);
});
it("returns false for receipt-style senderKeyDistribution-only payload (regression for #73797)", () => {
expect(
hasInboundUserContent({
senderKeyDistributionMessage: {
groupId: "g@example",
} as unknown as proto.Message.ISenderKeyDistributionMessage,
} as proto.IMessage),
).toBe(false);
});
it("returns false when location coords are missing (incomplete event, regression for #73797)", () => {
expect(
hasInboundUserContent({
locationMessage: { name: "no coords" },
} as proto.IMessage),
).toBe(false);
});
it("returns false when extendedTextMessage has only empty text", () => {
expect(hasInboundUserContent({ extendedTextMessage: { text: " " } } as proto.IMessage)).toBe(
false,
);
});
});

View File

@@ -438,3 +438,28 @@ export function describeReplyContext(
sender,
};
}
/**
* 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.
*/
export function hasInboundUserContent(rawMessage: proto.IMessage | undefined): boolean {
if (!rawMessage) {
return false;
}
if (extractText(rawMessage)) {
return true;
}
if (extractMediaPlaceholder(rawMessage)) {
return true;
}
if (extractContactContext(rawMessage)) {
return true;
}
if (extractLocationData(rawMessage)) {
return true;
}
return false;
}

View File

@@ -35,6 +35,7 @@ import {
extractMediaPlaceholder,
extractMentionedJids,
extractText,
hasInboundUserContent,
} from "./extract.js";
import { attachEmitterListener, closeInboundMonitorSocket } from "./lifecycle.js";
import { downloadInboundMedia } from "./media.js";
@@ -381,6 +382,18 @@ export async function attachWebInboxToSocket(
);
return null;
}
// Gate pairing access-control on extractable inbound user content. Baileys
// delivers receipts, typing indicators, presence updates, and protocol
// messages on the same `messages.upsert` stream as real messages; without
// this gate, `checkInboundAccessControl` can send an unsolicited pairing
// verification reply to a `dmPolicy: pairing` peer who never typed
// anything (e.g. when Master sends an outbound message to a new JID and
// the receipt round-trip arrives before the recipient ever replies).
// Echoes of our own outbound messages are already handled above.
if (!hasInboundUserContent(msg.message ?? undefined)) {
return null;
}
const participantJid = msg.key?.participant ?? undefined;
const from = group ? remoteJid : await resolveInboundJid(remoteJid);
if (!from) {