feat: add attachment reader/writer with RFC and download all media types

This commit is contained in:
linuztx 2026-03-26 13:06:40 +08:00
parent 8041c085d2
commit 00b6657185
4 changed files with 181 additions and 18 deletions

View file

@ -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))

View file

@ -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))

View file

@ -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

View file

@ -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]`;