Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions gateway/platforms/whatsapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 []:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}")
Expand Down
28 changes: 5 additions & 23 deletions scripts/whatsapp-bridge/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = '';
Expand Down Expand Up @@ -455,6 +436,7 @@ async function startSocket() {
quotedMessageId,
quotedParticipant,
quotedRemoteJid,
quotedText,
hasQuotedMessage,
botIds,
timestamp: msg.messageTimestamp,
Expand Down
136 changes: 136 additions & 0 deletions scripts/whatsapp-bridge/message-utils.js
Original file line number Diff line number Diff line change
@@ -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);
}
57 changes: 57 additions & 0 deletions scripts/whatsapp-bridge/message-utils.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading