diff --git a/plugins/_whatsapp_integration/helpers/attachment_reader.py b/plugins/_whatsapp_integration/helpers/attachment_reader.py new file mode 100644 index 000000000..ce9b3456a --- /dev/null +++ b/plugins/_whatsapp_integration/helpers/attachment_reader.py @@ -0,0 +1,40 @@ +""" +Read attachment files from execution runtime. + +No agent/tool dependencies. +""" + +import base64 +import os +from typing import TypedDict + + +# ------------------------------------------------------------------ +# Data models +# ------------------------------------------------------------------ + +class AttachmentData(TypedDict): + name: str + content_b64: str + error: str + + +# ------------------------------------------------------------------ +# File reader +# ------------------------------------------------------------------ + +def read_attachment(path: str) -> AttachmentData: + try: + if not os.path.isfile(path): + return AttachmentData( + name="", content_b64="", error=f"file not found: {path}") + name = os.path.basename(path) + with open(path, "rb") as f: + content = f.read() + return AttachmentData( + name=name, + content_b64=base64.b64encode(content).decode(), + error="", + ) + except Exception as e: + return AttachmentData(name="", content_b64="", error=str(e)) diff --git a/plugins/_whatsapp_integration/helpers/attachment_writer.py b/plugins/_whatsapp_integration/helpers/attachment_writer.py new file mode 100644 index 000000000..52d5e8a1a --- /dev/null +++ b/plugins/_whatsapp_integration/helpers/attachment_writer.py @@ -0,0 +1,35 @@ +""" +Write attachment files into execution runtime. + +No agent/tool dependencies. +""" + +import base64 +from typing import TypedDict + + +# ------------------------------------------------------------------ +# Data models +# ------------------------------------------------------------------ + +class WriteResult(TypedDict): + path: str + error: str + + +# ------------------------------------------------------------------ +# File writer +# ------------------------------------------------------------------ + +def write_attachment(rel_path: str, content_b64: str) -> WriteResult: + try: + from helpers import files + abs_path = files.get_abs_path(rel_path) + files.make_dirs(abs_path) + + content = base64.b64decode(content_b64) + files.write_file_bin(abs_path, content) + + return WriteResult(path=abs_path, error="") + except Exception as e: + return WriteResult(path="", error=str(e)) diff --git a/plugins/_whatsapp_integration/helpers/handler.py b/plugins/_whatsapp_integration/helpers/handler.py index 92d56dbc4..ce45508eb 100644 --- a/plugins/_whatsapp_integration/helpers/handler.py +++ b/plugins/_whatsapp_integration/helpers/handler.py @@ -5,10 +5,12 @@ Requires agent context. """ import asyncio +import base64 +import os import uuid from agent import Agent, AgentContext, UserMessage -from helpers import plugins +from helpers import plugins, files, runtime from helpers import message_queue as mq from helpers.persist_chat import save_tmp_chat from helpers.print_style import PrintStyle @@ -126,7 +128,8 @@ async def _start_new_chat(config: dict, msg: dict) -> None: system_ctx = context.agent0.read_prompt("fw.wa.system_context.md") msg_id = str(uuid.uuid4()) - attachments = msg.get("mediaUrls", []) + media_urls = msg.get("mediaUrls", []) + attachments = await _save_incoming_media(media_urls) if media_urls else [] mq.log_user_message( context, user_msg, attachments, message_id=msg_id, source=" (whatsapp)", ) @@ -154,7 +157,8 @@ async def _route_to_chat( user_msg = _build_user_message(context.agent0, msg, config) msg_id = str(uuid.uuid4()) - attachments = msg.get("mediaUrls", []) + media_urls = msg.get("mediaUrls", []) + attachments = await _save_incoming_media(media_urls) if media_urls else [] mq.log_user_message( context, user_msg, attachments, message_id=msg_id, source=" (whatsapp)", ) @@ -244,12 +248,13 @@ async def send_wa_reply( except Exception as e: return str(e) - # Send attachments + # Send attachments via RFC (files may live in execution runtime) if attachments: - for file_path in attachments: + host_paths = await _read_attachments_to_host(attachments) + for host_path in host_paths: try: result = await wa_client.send_media( - base_url, chat_id, file_path, + base_url, chat_id, host_path, ) if result.get("error"): PrintStyle.warning(f"WhatsApp: attachment error: {result['error']}") @@ -257,3 +262,55 @@ async def send_wa_reply( PrintStyle.warning(f"WhatsApp: attachment error: {e}") return None + + +# ------------------------------------------------------------------ +# Attachment reading (via RFC into execution runtime) +# ------------------------------------------------------------------ + +async def _read_attachments_to_host( + paths: list[str], +) -> list[str]: + """Read files from execution runtime and write to host media cache.""" + from plugins._whatsapp_integration.helpers.attachment_reader import read_attachment + + host_paths: list[str] = [] + for path in paths: + data = await runtime.call_development_function(read_attachment, path) + if data["error"]: + PrintStyle.warning(f"WhatsApp attachment: {data['error']}") + continue + # Write decoded bytes to host-side media cache + host_path = os.path.join( + files.get_abs_path(MEDIA_FOLDER), data["name"], + ) + os.makedirs(os.path.dirname(host_path), exist_ok=True) + with open(host_path, "wb") as f: + f.write(base64.b64decode(data["content_b64"])) + host_paths.append(host_path) + return host_paths + + +async def _save_incoming_media( + media_urls: list[str], +) -> list[str]: + """Save incoming media files into execution runtime via RFC.""" + from plugins._whatsapp_integration.helpers.attachment_writer import write_attachment + + runtime_paths: list[str] = [] + for host_path in media_urls: + if not os.path.isfile(host_path): + continue + name = os.path.basename(host_path) + with open(host_path, "rb") as f: + content_b64 = base64.b64encode(f.read()).decode() + rel_path = os.path.join(MEDIA_FOLDER, name) + result = await runtime.call_development_function( + write_attachment, rel_path, content_b64, + ) + if result.get("error"): + PrintStyle.warning(f"WhatsApp media save: {result['error']}") + runtime_paths.append(host_path) # fallback to host path + else: + runtime_paths.append(result["path"]) + return runtime_paths diff --git a/plugins/_whatsapp_integration/whatsapp-bridge/bridge.js b/plugins/_whatsapp_integration/whatsapp-bridge/bridge.js index 4f9a72176..d98bd0968 100644 --- a/plugins/_whatsapp_integration/whatsapp-bridge/bridge.js +++ b/plugins/_whatsapp_integration/whatsapp-bridge/bridge.js @@ -217,17 +217,6 @@ async function startSocket() { body = msg.message.imageMessage.caption || ''; hasMedia = true; mediaType = 'image'; - try { - const buf = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage }); - const mime = msg.message.imageMessage.mimetype || 'image/jpeg'; - const extMap = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif' }; - const ext = extMap[mime] || '.jpg'; - const filePath = path.join(CACHE_DIR, `img_${randomBytes(6).toString('hex')}${ext}`); - writeFileSync(filePath, buf); - mediaUrls.push(filePath); - } catch (err) { - console.error('[bridge] Failed to download image:', err.message); - } } else if (msg.message.videoMessage) { body = msg.message.videoMessage.caption || ''; hasMedia = true; @@ -236,11 +225,53 @@ async function startSocket() { hasMedia = true; mediaType = msg.message.pttMessage ? 'ptt' : 'audio'; } else if (msg.message.documentMessage) { - body = msg.message.documentMessage.caption || msg.message.documentMessage.fileName || ''; + body = msg.message.documentMessage.caption || ''; hasMedia = true; mediaType = 'document'; } + // Download media to disk + if (hasMedia) { + try { + const buf = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage }); + let ext = '.bin'; + let prefix = mediaType; + if (mediaType === 'image') { + const mime = msg.message.imageMessage?.mimetype || 'image/jpeg'; + const extMap = { 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif' }; + ext = extMap[mime] || '.jpg'; + } else if (mediaType === 'video') { + const mime = msg.message.videoMessage?.mimetype || 'video/mp4'; + ext = mime.includes('mp4') ? '.mp4' : '.mkv'; + } else if (mediaType === 'audio' || mediaType === 'ptt') { + const mime = msg.message.audioMessage?.mimetype || msg.message.pttMessage?.mimetype || 'audio/ogg'; + ext = mime.includes('opus') || mime.includes('ogg') ? '.ogg' : '.mp3'; + } else if (mediaType === 'document') { + const docMsg = msg.message.documentMessage; + const fileName = docMsg?.fileName || ''; + if (fileName) { + // Use original filename for documents + const filePath = path.join(CACHE_DIR, `${randomBytes(4).toString('hex')}_${fileName}`); + writeFileSync(filePath, buf); + mediaUrls.push(filePath); + if (!body) body = fileName; + } else { + const mime = docMsg?.mimetype || 'application/octet-stream'; + const docExtMap = { 'application/pdf': '.pdf', 'application/msword': '.doc' }; + ext = docExtMap[mime] || '.bin'; + } + } + // Write file if not already handled (document with fileName) + if (mediaUrls.length === 0) { + const filePath = path.join(CACHE_DIR, `${prefix}_${randomBytes(6).toString('hex')}${ext}`); + writeFileSync(filePath, buf); + mediaUrls.push(filePath); + } + } catch (err) { + console.error(`[bridge] Failed to download ${mediaType}:`, err.message); + } + } + // For media without caption, use a placeholder if (hasMedia && !body) { body = `[${mediaType} received]`;