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
166 changes: 166 additions & 0 deletions tests/tools/test_whatsapp_media.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
"""Tests for WhatsApp media delivery in send_message_tool.py.

The local Baileys bridge exposes POST /send (text) and POST /send-media
(native image/video/audio/document). These tests verify _send_whatsapp routes
text-only sends to /send and attachment sends to /send-media with the correct
payload (filePath, caption on the first attachment, mediaType=audio for voice).
"""

import asyncio
import os
from unittest.mock import AsyncMock, MagicMock, patch

from tools.send_message_tool import _send_whatsapp


def _make_aiohttp_resp(status, json_data=None, text_data=None):
"""Minimal async-context-manager mock for an aiohttp response."""
resp = AsyncMock()
resp.status = status
resp.json = AsyncMock(return_value=json_data or {})
resp.text = AsyncMock(return_value=text_data or "")
return resp


def _make_aiohttp_session(resp):
"""Wrap a response mock in a session mock that supports async-with for post."""
request_ctx = MagicMock()
request_ctx.__aenter__ = AsyncMock(return_value=resp)
request_ctx.__aexit__ = AsyncMock(return_value=False)

session = MagicMock()
session.post = MagicMock(return_value=request_ctx)

session_ctx = MagicMock()
session_ctx.__aenter__ = AsyncMock(return_value=session)
session_ctx.__aexit__ = AsyncMock(return_value=False)
return session_ctx, session


class TestSendWhatsAppText:
def test_text_only_uses_send_endpoint(self):
"""Backward compatibility: text-only goes to POST /send."""
resp = _make_aiohttp_resp(200, json_data={"messageId": "wamid.TEXT"})
session_ctx, session = _make_aiohttp_session(resp)

with patch("aiohttp.ClientSession", return_value=session_ctx):
result = asyncio.run(_send_whatsapp({"bridge_port": 3111}, "1@s.whatsapp.net", "hello"))

assert result == {
"success": True,
"platform": "whatsapp",
"chat_id": "1@s.whatsapp.net",
"message_id": "wamid.TEXT",
}
session.post.assert_called_once()
url, kwargs = session.post.call_args[0][0], session.post.call_args[1]
assert url == "http://localhost:3111/send"
assert kwargs["json"] == {"chatId": "1@s.whatsapp.net", "message": "hello"}

def test_text_http_error_is_returned(self):
resp = _make_aiohttp_resp(503, text_data="Not connected to WhatsApp")
session_ctx, _ = _make_aiohttp_session(resp)

with patch("aiohttp.ClientSession", return_value=session_ctx):
result = asyncio.run(_send_whatsapp({}, "1@s.whatsapp.net", "hi"))

assert "error" in result
assert "503" in result["error"]
assert "Not connected" in result["error"]


class TestSendWhatsAppMedia:
def test_image_uses_send_media_with_caption(self, tmp_path):
"""An image attachment goes to /send-media; text rides as the caption."""
img = tmp_path / "shot.png"
img.write_bytes(b"\x89PNG\r\n")
resp = _make_aiohttp_resp(200, json_data={"messageId": "wamid.IMG"})
session_ctx, session = _make_aiohttp_session(resp)

with patch("aiohttp.ClientSession", return_value=session_ctx):
result = asyncio.run(_send_whatsapp(
{"bridge_port": 3111},
"1@s.whatsapp.net",
"here is the screenshot",
media_files=[(str(img), False)],
))

assert result["success"] is True
assert result["message_id"] == "wamid.IMG"
# Only /send-media is hit (no separate text /send when an attachment carries the caption).
session.post.assert_called_once()
url, kwargs = session.post.call_args[0][0], session.post.call_args[1]
assert url == "http://localhost:3111/send-media"
assert kwargs["json"]["chatId"] == "1@s.whatsapp.net"
assert kwargs["json"]["filePath"] == str(img)
assert kwargs["json"]["caption"] == "here is the screenshot"
assert "mediaType" not in kwargs["json"] # image is inferred by the bridge

def test_voice_sets_audio_media_type(self, tmp_path):
"""A voice attachment is sent as mediaType=audio so the bridge renders ptt."""
voice = tmp_path / "note.ogg"
voice.write_bytes(b"OggS")
resp = _make_aiohttp_resp(200, json_data={"messageId": "wamid.PTT"})
session_ctx, session = _make_aiohttp_session(resp)

with patch("aiohttp.ClientSession", return_value=session_ctx):
result = asyncio.run(_send_whatsapp(
{"bridge_port": 3111},
"1@s.whatsapp.net",
"",
media_files=[(str(voice), True)],
))

assert result["success"] is True
kwargs = session.post.call_args[1]
assert kwargs["json"]["mediaType"] == "audio"
assert "caption" not in kwargs["json"] # no text → no caption

def test_caption_only_on_first_attachment(self, tmp_path):
"""With multiple attachments, only the first carries the caption."""
a = tmp_path / "a.png"; a.write_bytes(b"\x89PNG")
b = tmp_path / "b.png"; b.write_bytes(b"\x89PNG")
resp = _make_aiohttp_resp(200, json_data={"messageId": "wamid.X"})
session_ctx, session = _make_aiohttp_session(resp)

with patch("aiohttp.ClientSession", return_value=session_ctx):
result = asyncio.run(_send_whatsapp(
{}, "1@s.whatsapp.net", "caption text",
media_files=[(str(a), False), (str(b), False)],
))

assert result["success"] is True
assert session.post.call_count == 2
first_json = session.post.call_args_list[0][1]["json"]
second_json = session.post.call_args_list[1][1]["json"]
assert first_json["caption"] == "caption text"
assert "caption" not in second_json

def test_missing_media_file_errors(self):
resp = _make_aiohttp_resp(200, json_data={"messageId": "x"})
session_ctx, session = _make_aiohttp_session(resp)

with patch("aiohttp.ClientSession", return_value=session_ctx):
result = asyncio.run(_send_whatsapp(
{}, "1@s.whatsapp.net", "x",
media_files=[("/does/not/exist.png", False)],
))

assert "error" in result
assert "not found" in result["error"]
session.post.assert_not_called()

def test_media_bridge_http_error_is_returned(self, tmp_path):
img = tmp_path / "shot.png"
img.write_bytes(b"\x89PNG")
resp = _make_aiohttp_resp(500, text_data="boom")
session_ctx, _ = _make_aiohttp_session(resp)

with patch("aiohttp.ClientSession", return_value=session_ctx):
result = asyncio.run(_send_whatsapp(
{}, "1@s.whatsapp.net", "x", media_files=[(str(img), False)],
))

assert "error" in result
assert "500" in result["error"]
assert "boom" in result["error"]
89 changes: 71 additions & 18 deletions tools/send_message_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -734,19 +734,35 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
last_result = result
return last_result

# --- WhatsApp: native media via the local Baileys bridge /send-media ---
if platform == Platform.WHATSAPP and media_files:
last_result = None
for i, chunk in enumerate(chunks):
is_last = (i == len(chunks) - 1)
result = await _send_whatsapp(
pconfig.extra,
chat_id,
chunk,
media_files=media_files if is_last else None,
)
if isinstance(result, dict) and result.get("error"):
return result
last_result = result
return last_result

# --- Non-media platforms ---
if media_files and not message.strip():
return {
"error": (
f"send_message MEDIA delivery is currently only supported for telegram, discord, matrix, weixin, signal, yuanbao and feishu; "
f"send_message MEDIA delivery is currently only supported for telegram, discord, matrix, weixin, signal, yuanbao, feishu and whatsapp; "
f"target {platform.value} had only media attachments"
)
}
warning = None
if media_files:
warning = (
f"MEDIA attachments were omitted for {platform.value}; "
"native send_message media delivery is currently only supported for telegram, discord, matrix, weixin, signal, yuanbao and feishu"
"native send_message media delivery is currently only supported for telegram, discord, matrix, weixin, signal, yuanbao, feishu and whatsapp"
)

last_result = None
Expand Down Expand Up @@ -1057,30 +1073,67 @@ async def _send_slack(token, chat_id, message):
return _error(f"Slack send failed: {e}")


async def _send_whatsapp(extra, chat_id, message):
"""Send via the local WhatsApp bridge HTTP API."""
async def _send_whatsapp(extra, chat_id, message, media_files=None):
"""Send via the local WhatsApp bridge HTTP API.

Text-only messages go to POST /send. When ``media_files`` are present each
``(path, is_voice)`` entry is delivered natively via POST /send-media — the
same endpoint the in-gateway WhatsAppAdapter uses — with the message text
attached as the caption of the first attachment so text + media stay grouped
in one conversation. ``is_voice`` entries are sent as ``mediaType=audio`` so
the bridge renders a native voice bubble (ptt).
"""
try:
import aiohttp
except ImportError:
return {"error": "aiohttp not installed. Run: pip install aiohttp"}
try:
bridge_port = extra.get("bridge_port", 3000)
media = media_files or []
text = message or ""
async with aiohttp.ClientSession() as session:
async with session.post(
f"http://localhost:{bridge_port}/send",
json={"chatId": chat_id, "message": message},
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status == 200:
last_message_id = None

# Text-only send (when there is no attachment to carry the caption).
if text.strip() and not media:
async with session.post(
f"http://localhost:{bridge_port}/send",
json={"chatId": chat_id, "message": text},
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status != 200:
body = await resp.text()
return _error(f"WhatsApp bridge error ({resp.status}): {body}")
data = await resp.json()
last_message_id = data.get("messageId")

caption_used = False
for media_path, is_voice in media:
if not os.path.exists(media_path):
return _error(f"WhatsApp media file not found: {media_path}")
payload = {"chatId": chat_id, "filePath": media_path}
if is_voice:
payload["mediaType"] = "audio"
if text.strip() and not caption_used:
payload["caption"] = text
caption_used = True
async with session.post(
f"http://localhost:{bridge_port}/send-media",
json=payload,
timeout=aiohttp.ClientTimeout(total=120),
) as resp:
if resp.status != 200:
body = await resp.text()
return _error(f"WhatsApp media bridge error ({resp.status}): {body}")
data = await resp.json()
return {
"success": True,
"platform": "whatsapp",
"chat_id": chat_id,
"message_id": data.get("messageId"),
}
body = await resp.text()
return _error(f"WhatsApp bridge error ({resp.status}): {body}")
last_message_id = data.get("messageId")

return {
"success": True,
"platform": "whatsapp",
"chat_id": chat_id,
"message_id": last_message_id,
}
except Exception as e:
return _error(f"WhatsApp send failed: {e}")

Expand Down