diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index 85f810a2dfa2..f173f78f373e 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -459,6 +459,24 @@ def _normalize_whatsapp_id(value: Optional[str]) -> str: normalized = normalized.replace(":", "@", 1) return normalized + @staticmethod + def _sanitize_reply_context_text(value: Any, *, limit: int = 1000) -> str: + """Bound and scrub quoted WhatsApp text before exposing it to the agent.""" + if value is None: + return "" + text = str(value) + text = text.replace("\r\n", "\n").replace("\r", "\n") + # Strip non-newline control characters so quoted text cannot corrupt + # the synthetic [Replying to: "..."] prefix in gateway.run. + text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", " ", text) + text = re.sub(r"[ \t]+", " ", text) + text = re.sub(r"\n{3,}", "\n\n", text).strip() + if not text: + return "" + if len(text) <= limit: + return text + return text[:max(0, limit - 1)].rstrip() + "…" + def _bot_ids_from_message(self, data: Dict[str, Any]) -> set[str]: bot_ids = set() for candidate in data.get("botIds") or []: @@ -1408,6 +1426,12 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv body = data.get("body", "") if data.get("isGroup"): body = self._clean_bot_mention_text(body, data) + quoted_text = self._sanitize_reply_context_text(data.get("quotedText")) + quoted_message_id = str(data.get("quotedMessageId") or "").strip() + if quoted_text and not quoted_message_id: + # Baileys normally includes contextInfo.stanzaId, but keep the + # context usable if a backend only provides quoted text. + quoted_message_id = f"quoted:{data.get('messageId') or 'unknown'}" MAX_TEXT_INJECT_BYTES = 100 * 1024 if msg_type == MessageType.DOCUMENT and cached_urls: for doc_path in cached_urls: @@ -1443,6 +1467,8 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv message_id=data.get("messageId"), media_urls=cached_urls, media_types=media_types, + reply_to_message_id=quoted_message_id or None, + reply_to_text=quoted_text or None, ) except Exception as e: print(f"[{self.name}] Error building event: {e}") diff --git a/scripts/whatsapp-bridge/bridge.js b/scripts/whatsapp-bridge/bridge.js index 4c65740c0174..c2e2e00bcb45 100644 --- a/scripts/whatsapp-bridge/bridge.js +++ b/scripts/whatsapp-bridge/bridge.js @@ -30,6 +30,7 @@ import { execSync } from 'child_process'; import { tmpdir } from 'os'; import qrcode from 'qrcode-terminal'; import { matchesAllowedUser, parseAllowedUsers } from './allowlist.js'; +import { extractQuotedMessageText, getContextInfo, getMessageContent } from './message-utils.js'; // Parse CLI args const args = process.argv.slice(2); @@ -77,6 +78,7 @@ const REPLY_PREFIX = process.env.WHATSAPP_REPLY_PREFIX === undefined : process.env.WHATSAPP_REPLY_PREFIX.replace(/\\n/g, '\n'); const MAX_MESSAGE_LENGTH = parseInt(process.env.WHATSAPP_MAX_MESSAGE_LENGTH || '4096', 10); const CHUNK_DELAY_MS = parseInt(process.env.WHATSAPP_CHUNK_DELAY_MS || '300', 10); +const MAX_QUOTED_CONTEXT_LENGTH = 1000; // Per-call timeout for sock.sendMessage(). Baileys occasionally hangs forever // when uploading media to WhatsApp servers (and, less often, on text sends), // which pins the bridge's HTTP handler until the upstream aiohttp timeout @@ -144,28 +146,6 @@ function normalizeWhatsAppId(value) { return String(value).replace(':', '@'); } -function getMessageContent(msg) { - const content = msg?.message || {}; - if (content.ephemeralMessage?.message) return content.ephemeralMessage.message; - if (content.viewOnceMessage?.message) return content.viewOnceMessage.message; - if (content.viewOnceMessageV2?.message) return content.viewOnceMessageV2.message; - if (content.documentWithCaptionMessage?.message) return content.documentWithCaptionMessage.message; - if (content.templateMessage?.hydratedTemplate) return content.templateMessage.hydratedTemplate; - if (content.buttonsMessage) return content.buttonsMessage; - if (content.listMessage) return content.listMessage; - return content; -} - -function getContextInfo(messageContent) { - if (!messageContent || typeof messageContent !== 'object') return {}; - for (const value of Object.values(messageContent)) { - if (value && typeof value === 'object' && value.contextInfo) { - return value.contextInfo; - } - } - return {}; -} - mkdirSync(SESSION_DIR, { recursive: true }); // Build LID → phone reverse map from session files (lid-mapping-{phone}.json) @@ -340,7 +320,8 @@ async function startSocket() { const quotedMessageId = contextInfo?.stanzaId || null; const quotedParticipant = normalizeWhatsAppId(contextInfo?.participant || '') || null; const quotedRemoteJid = normalizeWhatsAppId(contextInfo?.remoteJid || '') || null; - const hasQuotedMessage = !!contextInfo?.quotedMessage; + const quotedText = extractQuotedMessageText(contextInfo, MAX_QUOTED_CONTEXT_LENGTH); + const hasQuotedMessage = !!contextInfo?.quotedMessage || !!quotedText; // Extract message body let body = ''; @@ -455,6 +436,7 @@ async function startSocket() { quotedMessageId, quotedParticipant, quotedRemoteJid, + quotedText, hasQuotedMessage, botIds, timestamp: msg.messageTimestamp, diff --git a/scripts/whatsapp-bridge/message-utils.js b/scripts/whatsapp-bridge/message-utils.js new file mode 100644 index 000000000000..e861af5368b6 --- /dev/null +++ b/scripts/whatsapp-bridge/message-utils.js @@ -0,0 +1,136 @@ +const DEFAULT_SNIPPET_LIMIT = 1000; + +function toPositiveLimit(value, fallback = DEFAULT_SNIPPET_LIMIT) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 1) return fallback; + return Math.floor(parsed); +} + +export function sanitizeMessageSnippet(value, maxLength = DEFAULT_SNIPPET_LIMIT) { + if (value === null || value === undefined) return ''; + const limit = toPositiveLimit(maxLength); + let text = String(value) + .replace(/\r\n?/g, '\n') + // Keep newlines, but strip other control characters before this text is + // injected into the Python-side reply-context prefix. + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ' ') + .replace(/[ \t]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim(); + + if (!text) return ''; + if (text.length <= limit) return text; + return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}…`; +} + +export function unwrapMessageContent(content) { + let current = content && typeof content === 'object' ? content : {}; + + for (let i = 0; i < 8; i += 1) { + if (current.ephemeralMessage?.message) { + current = current.ephemeralMessage.message; + continue; + } + if (current.viewOnceMessage?.message) { + current = current.viewOnceMessage.message; + continue; + } + if (current.viewOnceMessageV2?.message) { + current = current.viewOnceMessageV2.message; + continue; + } + if (current.documentWithCaptionMessage?.message) { + current = current.documentWithCaptionMessage.message; + continue; + } + if (current.templateMessage?.hydratedTemplate) { + return current.templateMessage.hydratedTemplate; + } + if (current.buttonsMessage) return current.buttonsMessage; + if (current.listMessage) return current.listMessage; + return current; + } + + return current; +} + +export function getMessageContent(msg) { + return unwrapMessageContent(msg?.message || {}); +} + +export function getContextInfo(messageContent) { + if (!messageContent || typeof messageContent !== 'object') return {}; + if (messageContent.contextInfo) return messageContent.contextInfo; + for (const value of Object.values(messageContent)) { + if (value && typeof value === 'object' && value.contextInfo) { + return value.contextInfo; + } + } + return {}; +} + +function mediaText(kind, caption, maxLength) { + const cleanCaption = sanitizeMessageSnippet(caption, maxLength); + return cleanCaption ? `[${kind} message] ${cleanCaption}` : `[${kind} message]`; +} + +export function extractMessageText(messageContent, maxLength = DEFAULT_SNIPPET_LIMIT) { + const content = unwrapMessageContent(messageContent); + let text = ''; + + if (content.conversation) { + text = content.conversation; + } else if (content.extendedTextMessage?.text) { + text = content.extendedTextMessage.text; + } else if (content.imageMessage) { + text = mediaText('image', content.imageMessage.caption || '', maxLength); + } else if (content.videoMessage) { + text = mediaText('video', content.videoMessage.caption || '', maxLength); + } else if (content.documentMessage) { + const fileName = content.documentMessage.fileName + ? `: ${content.documentMessage.fileName}` + : ''; + const caption = content.documentMessage.caption || ''; + const cleanCaption = sanitizeMessageSnippet(caption, maxLength); + text = cleanCaption + ? `[document message${fileName}] ${cleanCaption}` + : `[document message${fileName}]`; + } else if (content.audioMessage || content.pttMessage) { + text = content.pttMessage ? '[voice message]' : '[audio message]'; + } else if (content.stickerMessage) { + text = '[sticker message]'; + } else if (content.locationMessage) { + const name = content.locationMessage.name || content.locationMessage.address || ''; + text = name ? `[location message] ${name}` : '[location message]'; + } else if (content.contactMessage) { + const name = content.contactMessage.displayName || ''; + text = name ? `[contact message] ${name}` : '[contact message]'; + } else if (content.contactsArrayMessage?.contacts?.length) { + const names = content.contactsArrayMessage.contacts + .map(contact => contact.displayName) + .filter(Boolean) + .join(', '); + text = names ? `[contacts message] ${names}` : '[contacts message]'; + } else if (content.buttonsResponseMessage) { + text = content.buttonsResponseMessage.selectedDisplayText + || content.buttonsResponseMessage.selectedButtonId + || ''; + } else if (content.listResponseMessage) { + text = content.listResponseMessage.title + || content.listResponseMessage.singleSelectReply?.selectedRowId + || ''; + } else if (content.templateButtonReplyMessage) { + text = content.templateButtonReplyMessage.selectedDisplayText + || content.templateButtonReplyMessage.selectedId + || ''; + } else if (content.pollCreationMessage?.name || content.pollCreationMessageV3?.name) { + text = `[poll message] ${content.pollCreationMessage?.name || content.pollCreationMessageV3?.name}`; + } + + return sanitizeMessageSnippet(text, maxLength); +} + +export function extractQuotedMessageText(contextInfo, maxLength = DEFAULT_SNIPPET_LIMIT) { + if (!contextInfo?.quotedMessage) return ''; + return extractMessageText(contextInfo.quotedMessage, maxLength); +} diff --git a/scripts/whatsapp-bridge/message-utils.test.mjs b/scripts/whatsapp-bridge/message-utils.test.mjs new file mode 100644 index 000000000000..0c713881dd8c --- /dev/null +++ b/scripts/whatsapp-bridge/message-utils.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + extractQuotedMessageText, + getContextInfo, + getMessageContent, + sanitizeMessageSnippet, +} from './message-utils.js'; + +test('extracts text from a quoted WhatsApp conversation payload', () => { + const msg = { + message: { + extendedTextMessage: { + text: 'yes', + contextInfo: { + stanzaId: 'orig-msg-1', + participant: '15551234567@s.whatsapp.net', + quotedMessage: { + conversation: 'Should I book the 4pm flight?', + }, + }, + }, + }, + }; + + const content = getMessageContent(msg); + const contextInfo = getContextInfo(content); + + assert.equal(extractQuotedMessageText(contextInfo), 'Should I book the 4pm flight?'); +}); + +test('extracts caption/type context from quoted media payloads', () => { + const contextInfo = { + quotedMessage: { + imageMessage: { + caption: 'Use this design for the launch post', + }, + }, + }; + + assert.equal( + extractQuotedMessageText(contextInfo), + '[image message] Use this design for the launch post', + ); +}); + +test('quoted snippets are sanitized and bounded', () => { + const dirty = `first${String.fromCharCode(0, 7)}\r\n${'x'.repeat(1200)}`; + const snippet = sanitizeMessageSnippet(dirty, 80); + + assert.equal(snippet.includes(String.fromCharCode(0)), false); + assert.equal(snippet.includes(String.fromCharCode(7)), false); + assert.equal(snippet.includes('\r'), false); + assert.equal(snippet.endsWith('…'), true); + assert.equal(snippet.length <= 80, true); +}); diff --git a/tests/gateway/test_whatsapp_reply_context.py b/tests/gateway/test_whatsapp_reply_context.py new file mode 100644 index 000000000000..8c6f459c2798 --- /dev/null +++ b/tests/gateway/test_whatsapp_reply_context.py @@ -0,0 +1,111 @@ +"""Regression tests for WhatsApp quoted/replied-message context.""" + +import pytest + +from gateway.config import GatewayConfig, Platform, PlatformConfig +from gateway.platforms.whatsapp import WhatsAppAdapter +from gateway.session import SessionSource + + +def _make_adapter(**extra): + base = {"session_name": "test"} + base.update(extra) + return WhatsAppAdapter(PlatformConfig(enabled=True, extra=base)) + + +def _bridge_reply_payload(**overrides): + data = { + "messageId": "reply-msg-1", + "chatId": "15551234567@s.whatsapp.net", + "senderId": "15551234567@s.whatsapp.net", + "senderName": "Alice", + "chatName": "Alice", + "isGroup": False, + "body": "yes, do that", + "hasMedia": False, + "mediaUrls": [], + "mediaType": "", + "quotedMessageId": "orig-msg-1", + "quotedText": "Should I book the 4pm flight?", + "hasQuotedMessage": True, + } + data.update(overrides) + return data + + +@pytest.mark.asyncio +async def test_bridge_quoted_text_becomes_message_event_reply_context(): + adapter = _make_adapter() + + event = await adapter._build_message_event(_bridge_reply_payload()) + + assert event is not None + assert event.text == "yes, do that" + assert event.reply_to_message_id == "orig-msg-1" + assert event.reply_to_text == "Should I book the 4pm flight?" + assert event.raw_message["quotedText"] == "Should I book the 4pm flight?" + + +@pytest.mark.asyncio +async def test_quoted_text_without_stanza_id_still_gets_synthetic_reply_id(): + adapter = _make_adapter() + + event = await adapter._build_message_event( + _bridge_reply_payload(quotedMessageId=None, quotedText="Use the blue design") + ) + + assert event is not None + assert event.reply_to_message_id == "quoted:reply-msg-1" + assert event.reply_to_text == "Use the blue design" + + +def test_reply_context_sanitizer_bounds_and_strips_control_chars(): + quoted = "line one\x00\x07\r\n" + ("x" * 1100) + + sanitized = WhatsAppAdapter._sanitize_reply_context_text(quoted) + + assert "\x00" not in sanitized + assert "\x07" not in sanitized + assert "\r" not in sanitized + assert sanitized.startswith("line one") + assert sanitized.endswith("…") + assert len(sanitized) <= 1000 + + +@pytest.mark.asyncio +async def test_gateway_prepares_agent_visible_reply_context_prefix(): + from gateway.platforms.base import MessageEvent + from gateway.run import GatewayRunner + + runner = GatewayRunner.__new__(GatewayRunner) + runner.config = GatewayConfig(group_sessions_per_user=True) + runner.adapters = {} + setattr(runner, "_model", "test-model") + setattr(runner, "_base_url", "") + runner._has_setup_skill = lambda: False + + source = SessionSource( + platform=Platform.WHATSAPP, + chat_id="15551234567@s.whatsapp.net", + chat_type="dm", + user_id="15551234567@s.whatsapp.net", + user_name="Alice", + ) + event = MessageEvent( + text="yes, do that", + source=source, + message_id="reply-msg-1", + reply_to_message_id="orig-msg-1", + reply_to_text="Should I book the 4pm flight?", + ) + + prepared = await runner._prepare_inbound_message_text( + event=event, + source=source, + history=[], + ) + + assert prepared == ( + '[Replying to: "Should I book the 4pm flight?"]\n\n' + "yes, do that" + )