diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts index 46dcbe4c9..1b4fb3b9b 100644 --- a/bridge/src/whatsapp.ts +++ b/bridge/src/whatsapp.ts @@ -30,6 +30,7 @@ export interface InboundMessage { content: string; timestamp: number; isGroup: boolean; + isForwarded?: boolean; wasMentioned?: boolean; isReplyToBot?: boolean; media?: string[]; @@ -97,6 +98,10 @@ export class WhatsAppClient { return { wasMentioned, isReplyToBot }; } + private isForwarded(msg: any): boolean { + return this.messageContextInfos(msg).some((info) => Boolean(info?.isForwarded)); + } + async connect(): Promise { const logger = pino({ level: 'silent' }); const { state, saveCreds } = await useMultiFileAuthState(this.options.authDir); @@ -104,6 +109,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: { @@ -168,6 +177,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; @@ -192,8 +205,24 @@ 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'); } + const isForwarded = this.isForwarded(msg); + const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || ''; if (!finalContent && mediaPaths.length === 0) continue; @@ -208,6 +237,7 @@ export class WhatsAppClient { content: finalContent, timestamp: msg.messageTimestamp as number, isGroup, + ...(isForwarded ? { isForwarded } : {}), ...(isGroup ? { wasMentioned: wasMentioned || isReplyToBot, isReplyToBot } : {}), ...(mediaPaths.length > 0 ? { media: mediaPaths } : {}), }); 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())