fix: extract quoted WhatsApp reply text

This commit is contained in:
Peter Steinberger
2025-12-26 10:50:41 +01:00
parent 4611e799b7
commit 2045487d5e
4 changed files with 141 additions and 27 deletions

View File

@@ -20,6 +20,7 @@
- Heartbeat replies now strip repeated `HEARTBEAT_OK` tails to avoid accidental “OK OK” spam.
- Heartbeat failure logs now include the error reason instead of `[object Object]`.
- Duration strings now accept `h` (hours) where durations are parsed (e.g., heartbeat intervals).
- WhatsApp inbound now normalizes more wrapper types so quoted reply bodies are extracted reliably.
- WhatsApp send now preserves existing JIDs (including group `@g.us`) instead of coercing to `@s.whatsapp.net`. (Thanks @arun-8687.)
- Telegram/WhatsApp: reply context stays in `Body`/`ReplyTo*`, but outbound replies no longer thread to the original message. (Thanks @joshp123 for the PR and follow-up question.)
- WhatsApp web creds persistence hardened; credentials are restored before auth checks and QR login auto-restarts if it stalls.

View File

@@ -17,6 +17,22 @@ describe("web inbound helpers", () => {
expect(body).toBe("caption");
});
it("handles document captions", () => {
const body = extractText({
documentMessage: { caption: " doc " },
} as unknown as import("@whiskeysockets/baileys").proto.IMessage);
expect(body).toBe("doc");
});
it("unwraps view-once v2 extension messages", () => {
const body = extractText({
viewOnceMessageV2Extension: {
message: { conversation: " hello " },
},
} as unknown as import("@whiskeysockets/baileys").proto.IMessage);
expect(body).toBe("hello");
});
it("returns placeholders for media-only payloads", () => {
expect(
extractMediaPlaceholder({

View File

@@ -6,7 +6,10 @@ import type {
import {
DisconnectReason,
downloadMediaMessage,
extractMessageContent,
getContentType,
isJidGroup,
normalizeMessageContent,
} from "@whiskeysockets/baileys";
import { loadConfig } from "../config/config.js";
@@ -405,17 +408,47 @@ export async function monitorWebInbox(options: {
function unwrapMessage(
message: proto.IMessage | undefined,
): proto.IMessage | undefined {
const normalized = normalizeMessageContent(
message as proto.IMessage | undefined,
);
return normalized as proto.IMessage | undefined;
}
function extractContextInfo(
message: proto.IMessage | undefined,
): proto.IContextInfo | undefined {
if (!message) return undefined;
if (message.ephemeralMessage?.message) {
return unwrapMessage(message.ephemeralMessage.message as proto.IMessage);
const contentType = getContentType(message);
const candidate = contentType
? (message as Record<string, unknown>)[contentType]
: undefined;
const contextInfo =
candidate && typeof candidate === "object" && "contextInfo" in candidate
? (candidate as { contextInfo?: proto.IContextInfo }).contextInfo
: undefined;
if (contextInfo) return contextInfo;
const fallback =
message.extendedTextMessage?.contextInfo ??
message.imageMessage?.contextInfo ??
message.videoMessage?.contextInfo ??
message.documentMessage?.contextInfo ??
message.audioMessage?.contextInfo ??
message.stickerMessage?.contextInfo ??
message.buttonsResponseMessage?.contextInfo ??
message.listResponseMessage?.contextInfo ??
message.templateButtonReplyMessage?.contextInfo ??
message.interactiveResponseMessage?.contextInfo ??
message.buttonsMessage?.contextInfo ??
message.listMessage?.contextInfo;
if (fallback) return fallback;
for (const value of Object.values(message)) {
if (!value || typeof value !== "object") continue;
if (!("contextInfo" in value)) continue;
const candidateContext = (value as { contextInfo?: proto.IContextInfo })
.contextInfo;
if (candidateContext) return candidateContext;
}
if (message.viewOnceMessage?.message) {
return unwrapMessage(message.viewOnceMessage.message as proto.IMessage);
}
if (message.viewOnceMessageV2?.message) {
return unwrapMessage(message.viewOnceMessageV2.message as proto.IMessage);
}
return message;
return undefined;
}
function extractMentionedJids(
@@ -448,14 +481,27 @@ export function extractText(
): string | undefined {
const message = unwrapMessage(rawMessage);
if (!message) return undefined;
if (typeof message.conversation === "string" && message.conversation.trim()) {
return message.conversation.trim();
const extracted = extractMessageContent(message);
const candidates = [
message,
extracted && extracted !== message ? extracted : undefined,
];
for (const candidate of candidates) {
if (!candidate) continue;
if (
typeof candidate.conversation === "string" &&
candidate.conversation.trim()
) {
return candidate.conversation.trim();
}
const extended = candidate.extendedTextMessage?.text;
if (extended?.trim()) return extended.trim();
const caption =
candidate.imageMessage?.caption ??
candidate.videoMessage?.caption ??
candidate.documentMessage?.caption;
if (caption?.trim()) return caption.trim();
}
const extended = message.extendedTextMessage?.text;
if (extended?.trim()) return extended.trim();
const caption =
message.imageMessage?.caption ?? message.videoMessage?.caption;
if (caption?.trim()) return caption.trim();
return undefined;
}
@@ -479,19 +525,21 @@ function describeReplyContext(rawMessage: proto.IMessage | undefined): {
} | null {
const message = unwrapMessage(rawMessage);
if (!message) return null;
const contextInfo =
message.extendedTextMessage?.contextInfo ??
message.imageMessage?.contextInfo ??
message.videoMessage?.contextInfo ??
message.documentMessage?.contextInfo ??
message.audioMessage?.contextInfo ??
message.stickerMessage?.contextInfo ??
message.buttonsResponseMessage?.contextInfo ??
message.listResponseMessage?.contextInfo;
const quoted = contextInfo?.quotedMessage as proto.IMessage | undefined;
const contextInfo = extractContextInfo(message);
const quoted = normalizeMessageContent(
contextInfo?.quotedMessage as proto.IMessage | undefined,
) as proto.IMessage | undefined;
if (!quoted) return null;
const body = extractText(quoted) ?? extractMediaPlaceholder(quoted);
if (!body) return null;
if (!body) {
const quotedType = quoted ? getContentType(quoted) : undefined;
logVerbose(
`Quoted message missing extractable body${
quotedType ? ` (type ${quotedType})` : ""
}`,
);
return null;
}
const senderJid = contextInfo?.participant ?? undefined;
const senderE164 = senderJid
? (jidToE164(senderJid) ?? senderJid)

View File

@@ -198,6 +198,55 @@ describe("web monitor inbox", () => {
await listener.close();
});
it("captures reply context from wrapped quoted messages", async () => {
const onMessage = vi.fn(async (msg) => {
await msg.reply("pong");
});
const listener = await monitorWebInbox({ verbose: false, onMessage });
const sock = await createWaSocket();
const upsert = {
type: "notify",
messages: [
{
key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" },
message: {
extendedTextMessage: {
text: "reply",
contextInfo: {
stanzaId: "q1",
participant: "111@s.whatsapp.net",
quotedMessage: {
viewOnceMessageV2Extension: {
message: { conversation: "original" },
},
},
},
},
},
messageTimestamp: 1_700_000_000,
pushName: "Tester",
},
],
};
sock.ev.emit("messages.upsert", upsert);
await new Promise((resolve) => setImmediate(resolve));
expect(onMessage).toHaveBeenCalledWith(
expect.objectContaining({
replyToId: "q1",
replyToBody: "original",
replyToSender: "+111",
}),
);
expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", {
text: "pong",
});
await listener.close();
});
it("captures media path for image messages", async () => {
const onMessage = vi.fn();
const listener = await monitorWebInbox({ verbose: false, onMessage });