From eec59c05dec05732b51b6ba9c9455f7c51ea4144 Mon Sep 17 00:00:00 2001 From: comadreja Date: Sat, 6 Jun 2026 12:25:24 -0500 Subject: [PATCH 1/2] feat(bridge): WhatsApp forwarded message detection, startup guard, and contact handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three improvements to the WhatsApp Baileys bridge: 1. Forwarded message detection: Extracts contextInfo.isForwarded from all message types (text, image, video, audio, document) and passes it as isForwarded in the InboundMessage. Allows the agent to distinguish forwarded content from direct messages — useful for different handling (e.g., transcribe-only vs execute as instruction). 2. Startup timestamp guard: Records the timestamp when the bridge starts and drops any messages with messageTimestamp older than startup time. Prevents replaying message history on reconnect, which caused duplicate processing and stale command execution. 3. Contact message handling: Adds support for contactMessage and contactsArrayMessage types, extracting displayName and vcard data instead of silently dropping shared contacts. Changes: - Add isForwarded field to InboundMessage interface - Add startupTimestamp guard in message processing loop - Add contactMessage/contactsArrayMessage extraction - Extract contextInfo.isForwarded from all media message types --- bridge/src/whatsapp.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts index 0d2f40b2e..0a5b9eb67 100644 --- a/bridge/src/whatsapp.ts +++ b/bridge/src/whatsapp.ts @@ -29,6 +29,7 @@ export interface InboundMessage { content: string; timestamp: number; isGroup: boolean; + isForwarded?: boolean; wasMentioned?: boolean; media?: string[]; } @@ -81,6 +82,10 @@ export class WhatsAppClient { console.log(`Using Baileys version: ${version.join('.')}`); + // Record startup time — messages older than this will be ignored + // to avoid replaying history on reconnect + const startupTimestamp = Math.floor(Date.now() / 1000); + // Create socket following OpenClaw's pattern this.sock = makeWASocket({ auth: { @@ -145,6 +150,10 @@ export class WhatsAppClient { if (msg.key.fromMe) continue; if (msg.key.remoteJid === 'status@broadcast') continue; + // Drop messages older than startup time (avoid replaying history on reconnect) + const msgTimestamp = msg.messageTimestamp as number; + if (msgTimestamp && msgTimestamp < startupTimestamp) continue; + const unwrapped = baileysExtractMessageContent(msg.message); if (!unwrapped) continue; @@ -169,8 +178,30 @@ export class WhatsAppClient { fallbackContent = '[Voice Message]'; const path = await this.downloadMedia(msg, unwrapped.audioMessage.mimetype ?? undefined); if (path) mediaPaths.push(path); + } else if (unwrapped.contactMessage) { + // Single shared contact + const displayName = unwrapped.contactMessage.displayName || ''; + const vcard = unwrapped.contactMessage.vcard || ''; + fallbackContent = `[Contact: ${displayName}]\n${vcard}`; + } else if (unwrapped.contactsArrayMessage) { + // Multiple shared contacts + const vcards = unwrapped.contactsArrayMessage.contacts || []; + const parts = vcards.map((c: any) => { + const name = c.displayName || ''; + const vc = c.vcard || ''; + return `[Contact: ${name}]\n${vc}`; + }); + fallbackContent = parts.join('\n\n'); } + // Detect forwarded messages + const contextInfo = msg.message?.extendedTextMessage?.contextInfo + || msg.message?.imageMessage?.contextInfo + || msg.message?.videoMessage?.contextInfo + || msg.message?.audioMessage?.contextInfo + || msg.message?.documentMessage?.contextInfo; + const isForwarded = contextInfo?.isForwarded || false; + const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || ''; if (!finalContent && mediaPaths.length === 0) continue; @@ -184,6 +215,7 @@ export class WhatsAppClient { content: finalContent, timestamp: msg.messageTimestamp as number, isGroup, + ...(isForwarded ? { isForwarded } : {}), ...(isGroup ? { wasMentioned } : {}), ...(mediaPaths.length > 0 ? { media: mediaPaths } : {}), }); From e1ff0f37d9b6fcec649804a88acbf3cd01bcbb29 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Fri, 12 Jun 2026 18:21:47 +0800 Subject: [PATCH 2/2] fix: preserve WhatsApp forwarded metadata maintainer edit: carry the bridge isForwarded flag into channel metadata so forwarded voice messages remain distinguishable after transcription. --- nanobot/channels/whatsapp.py | 1 + tests/channels/test_whatsapp_channel.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 268b62f31..a989df03c 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -290,6 +290,7 @@ class WhatsAppChannel(BaseChannel): "message_id": message_id, "timestamp": data.get("timestamp"), "is_group": data.get("isGroup", False), + "is_forwarded": bool(data.get("isForwarded", False)), "participant": participant or None, "is_reply_to_bot": data.get("isReplyToBot", False), }, diff --git a/tests/channels/test_whatsapp_channel.py b/tests/channels/test_whatsapp_channel.py index cb5fc639b..04d498dee 100644 --- a/tests/channels/test_whatsapp_channel.py +++ b/tests/channels/test_whatsapp_channel.py @@ -291,6 +291,30 @@ async def test_voice_message_transcription_uses_media_path(): assert kwargs["content"].startswith("Hello world") +@pytest.mark.asyncio +async def test_forwarded_voice_message_preserves_metadata_after_transcription(): + ch = WhatsAppChannel({"enabled": True, "allowFrom": ["*"]}, MagicMock()) + ch._handle_message = AsyncMock() + ch.transcribe_audio = AsyncMock(return_value="Forwarded audio text") + + await ch._handle_bridge_message( + json.dumps({ + "type": "message", + "id": "v-forwarded", + "sender": "12345@s.whatsapp.net", + "pn": "", + "content": "[Voice Message]", + "timestamp": 1, + "media": ["/tmp/voice.ogg"], + "isForwarded": True, + }) + ) + + kwargs = ch._handle_message.await_args.kwargs + assert kwargs["content"] == "Forwarded audio text" + assert kwargs["metadata"]["is_forwarded"] is True + + @pytest.mark.asyncio async def test_unauthorized_voice_message_does_not_transcribe() -> None: ch = WhatsAppChannel({"enabled": True, "allowFrom": ["allowed"]}, MagicMock())