mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-07 07:58:36 +00:00
Merged via squash.
Prepared head SHA: 61506e1439
Co-authored-by: hclsys <7755017+hclsys@users.noreply.github.com>
Co-authored-by: mcaxtr <7562095+mcaxtr@users.noreply.github.com>
Reviewed-by: @mcaxtr
This commit is contained in:
@@ -73,6 +73,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Auto-reply/session: carry the tail of user/assistant turns into the freshly-rotated transcript on silent in-reply session resets (compaction failure, role-ordering conflict) so direct-chat continuity survives the rebind. Fixes #70853. (#70898) Thanks @neeravmakwana.
|
||||
- Config: skip malformed non-string `env.vars` entries before env-reference checks, so config loading no longer crashes on JSON values like numbers or booleans. (#42402) Thanks @MiltonHeYan.
|
||||
- Docker Compose: default missing config and workspace bind mounts to `${HOME:-/tmp}/.openclaw` so manual compose runs do not create invalid empty-source volume specs. (#64485) Thanks @jlapenna.
|
||||
- Channels/WhatsApp: restrict pairing verification replies to real inbound user content, preventing unsolicited prompts from receipts, typing indicators, presence updates, and other non-message Baileys upserts. Fixes #73797. (#73823) Thanks @hclsys.
|
||||
|
||||
## 2026.4.27
|
||||
|
||||
|
||||
@@ -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,182 @@ 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 true for contactsArrayMessage via contact placeholder extraction", () => {
|
||||
expect(
|
||||
hasInboundUserContent({
|
||||
contactsArrayMessage: {
|
||||
contacts: [{ displayName: "Alice", vcard: "BEGIN:VCARD\nEND:VCARD" }],
|
||||
},
|
||||
} as proto.IMessage),
|
||||
).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);
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -438,3 +438,49 @@ export function describeReplyContext(
|
||||
sender,
|
||||
};
|
||||
}
|
||||
|
||||
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 check that a Baileys message carries user-visible inbound content
|
||||
* (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) {
|
||||
return false;
|
||||
}
|
||||
if (extractText(rawMessage)) {
|
||||
return true;
|
||||
}
|
||||
if (extractMediaPlaceholder(rawMessage)) {
|
||||
return true;
|
||||
}
|
||||
if (extractLocationData(rawMessage)) {
|
||||
return true;
|
||||
}
|
||||
// Walk wrappers (ephemeral, viewOnce, etc.) — interactive responses
|
||||
// can arrive nested.
|
||||
for (const candidate of buildMessageChain(rawMessage)) {
|
||||
if (hasInteractiveResponseContent(candidate)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user