{"repo":"NousResearch/hermes-agent","public_id":"41405","display_label":"WhatsApp media attachment sending via Baileys bridge","summary":"These items all concern adding support for sending media attachments (images, videos, documents) through WhatsApp using a Baileys bridge endpoint. The work spans native media delivery and integration with a /send-media bridge API.","subsystem":"gateway","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":53598,"merged_at":"2026-06-27T11:40:05+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-06-27T11:40:05+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":41405,"pr_number":53598,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-27T11:40:05+00:00"}],"members":[{"kind":"issue","number":41405,"title":"[Feature] WhatsApp: send_message media attachment support (bridge already exposes /send-media)","url":null,"state":"closed"},{"kind":"pr","number":41426,"title":"feat(whatsapp): add media attachment support via bridge /send-media endpoint","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#32947","number":32947},{"kind":"pr","value":"#41426","number":41426}],"category":"superseded_by_pr","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":41466,"title":"feat(send_message): add WhatsApp media attachment support via Baileys bridge","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"cd592c105cbbc0bbf927b30ee9d061b1dd7b0b1b","number":0},{"kind":"pr","value":"#53598","number":53598},{"kind":"pr","value":"#41112","number":41112}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":53598,"title":"feat(send_message): native WhatsApp media delivery via Baileys bridge","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/platforms/whatsapp/adapter.py","tests/tools/test_whatsapp_send_message_media.py","tools/send_message_tool.py"],"fix_hunks":[{"file":"plugins/platforms/whatsapp/adapter.py","hunks":["@@ -1293,6 +1293,31 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv\n # ──────────────────────────────────────────────────────────────────────────\n \n \n+_WA_IMAGE_EXTS = {\".jpg\", \".jpeg\", \".png\", \".webp\", \".gif\"}\n+_WA_VIDEO_EXTS = {\".mp4\", \".mov\", \".avi\", \".mkv\", \".webm\", \".3gp\"}\n+_WA_AUDIO_EXTS = {\".ogg\", \".opus\", \".mp3\", \".wav\", \".m4a\", \".flac\"}\n+\n+\n+def _bridge_media_type(file_path: str, is_voice: bool, force_document: bool) -> str:\n+    \"\"\"Map a local media file to the bridge /send-media ``mediaType``.\n+\n+    Returns one of ``image`` | ``video`` | ``audio`` | ``document`` so the\n+    Baileys bridge renders the right native WhatsApp message kind. Voice notes\n+    and audio files route to ``audio``; ``force_document`` (the [[as_document]]\n+    directive) forces every file to ``document`` regardless of extension.\n+    \"\"\"\n+    if force_document:\n+        return \"document\"\n+    ext = os.path.splitext(file_path)[1].lower()\n+    if is_voice or ext in _WA_AUDIO_EXTS:\n+        return \"audio\"\n+    if ext in _WA_IMAGE_EXTS:\n+        return \"image\"\n+    if ext in _WA_VIDEO_EXTS:\n+        return \"video\"\n+    return \"document\"\n+\n+\n async def _standalone_send(\n     pconfig,\n     chat_id,","@@ -1316,22 +1341,55 @@ async def _standalone_send(\n     try:\n         bridge_port = extra.get(\"bridge_port\", 3000)\n         normalized_chat_id = to_whatsapp_jid(chat_id)\n+        media = media_files or []\n+        text = message or \"\"\n+        last_message_id = None\n         async with aiohttp.ClientSession() as session:\n-            async with session.post(\n-                f\"http://localhost:{bridge_port}/send\",\n-                json={\"chatId\": normalized_chat_id, \"message\": message},\n-                timeout=aiohttp.ClientTimeout(total=30),\n-            ) as resp:\n-                if resp.status == 200:\n+            # 1) Text first (skip the /send call when this chunk is media-only).\n+            if text.strip():\n+                async with session.post(\n+                    f\"http://localhost:{bridge_port}/send\",\n+                    json={\"chatId\": normalized_chat_id, \"message\": text},\n+                    timeout=aiohttp.ClientTimeout(total=30),\n+                ) as resp:\n+                    if resp.status != 200:\n+                        body = await resp.text()\n+                        return {\"error\": f\"WhatsApp bridge error ({resp.status}): {body}\"}\n                     data = await resp.json()\n-                    return {\n-                        \"success\": True,\n-                        \"platform\": \"whatsapp\",\n-                        \"chat_id\": normalized_chat_id,\n-                        \"message_id\": data.get(\"messageId\"),\n-                    }\n-                body = await resp.text()\n-                return {\"error\": f\"WhatsApp bridge error ({resp.status}): {body}\"}\n+                    last_message_id = data.get(\"messageId\")\n+\n+            # 2) Each media file as a native attachment via /send-media. The\n+            # bridge maps mediaType -> image/video/audio/document message kinds\n+            # so PNG/JPEG/WebP/GIF arrive as inline images, MP4 as a video\n+            # bubble, and ogg/opus as a voice note — not a file/document.\n+            for media_path, is_voice in media:\n+                if not os.path.exists(media_path):\n+                    return {\"error\": f\"WhatsApp media file not found: {media_path}\"}\n+                media_type = _bridge_media_type(media_path, is_voice, force_document)\n+                payload: Dict[str, Any] = {\n+                    \"chatId\": normalized_chat_id,\n+                    \"filePath\": media_path,\n+                    \"mediaType\": media_type,\n+                }\n+                if media_type == \"document\":\n+                    payload[\"fileName\"] = os.path.basename(media_path)\n+                async with session.post(\n+                    f\"http://localhost:{bridge_port}/send-media\",\n+                    json=payload,\n+                    timeout=aiohttp.ClientTimeout(total=120),\n+                ) as resp:\n+                    if resp.status != 200:\n+                        body = await resp.text()\n+                        return {\"error\": f\"WhatsApp media error ({resp.status}): {body}\"}\n+                    data = await resp.json()\n+                    last_message_id = data.get(\"messageId\") or last_message_id\n+\n+        return {\n+            \"success\": True,\n+            \"platform\": \"whatsapp\",\n+            \"chat_id\": normalized_chat_id,\n+            \"message_id\": last_message_id,\n+        }\n     except Exception as e:\n         return {\"error\": f\"WhatsApp send failed: {e}\"}\n "]},{"file":"tests/tools/test_whatsapp_send_message_media.py","hunks":["@@ -0,0 +1,221 @@\n+\"\"\"WhatsApp media delivery for send_message (#19105).\n+\n+Covers two layers:\n+\n+* ``_bridge_media_type`` — extension/voice/force_document -> bridge mediaType.\n+* ``_standalone_send`` — text-first then per-file ``/send-media`` uploads,\n+  media-only (skip ``/send``), and missing-file errors. The bridge HTTP calls\n+  are mocked at the ``aiohttp.ClientSession`` boundary.\n+\"\"\"\n+\n+import asyncio\n+import os\n+import tempfile\n+from types import SimpleNamespace\n+from unittest.mock import AsyncMock, MagicMock, patch\n+\n+import pytest\n+\n+from plugins.platforms.whatsapp.adapter import _bridge_media_type, _standalone_send\n+\n+\n+# ---------------------------------------------------------------------------\n+# _bridge_media_type\n+# ---------------------------------------------------------------------------\n+\n+\n[redacted:email](\n+    \"path,is_voice,force_document,expected\",\n+    [\n+        (\"a.png\", False, False, \"image\"),\n+        (\"a.JPG\", False, False, \"image\"),\n+        (\"a.jpeg\", False, False, \"image\"),\n+        (\"a.webp\", False, False, \"image\"),\n+        (\"a.gif\", False, False, \"image\"),\n+        (\"a.mp4\", False, False, \"video\"),\n+        (\"a.mov\", False, False, \"video\"),\n+        (\"a.webm\", False, False, \"video\"),\n+        (\"a.ogg\", True, False, \"audio\"),\n+        (\"a.opus\", False, False, \"audio\"),\n+        (\"a.mp3\", False, False, \"audio\"),\n+        (\"a.wav\", False, False, \"audio\"),\n+        (\"a.pdf\", False, False, \"document\"),\n+        (\"a.zip\", False, False, \"document\"),\n+        # force_document overrides everything\n+        (\"a.png\", False, True, \"document\"),\n+        (\"a.mp4\", False, True, \"document\"),\n+        # is_voice wins over a video extension\n+        (\"a.mp4\", True, False, \"audio\"),\n+    ],\n+)\n+def test_bridge_media_type(path, is_voice, force_document, expected):\n+    assert _bridge_media_type(path, is_voice, force_document) == expected\n+\n+\n+# ---------------------------------------------------------------------------\n+# _standalone_send — bridge HTTP mocked\n+# ---------------------------------------------------------------------------\n+\n+\n+def _resp(status, json_data=None, text_data=None):\n+    r = AsyncMock()\n+    r.status = status\n+    r.json = AsyncMock(return_value=json_data or {})\n+    r.text = AsyncMock(return_value=text_data or \"\")\n+    return r\n+\n+\n+def _session_with(responses):\n+    \"\"\"Build a mocked aiohttp.ClientSession that returns *responses* in order\n+    and records every POST (url, json_payload).\"\"\"\n+    calls = []\n+    idx = [0]\n+\n+    def _post(url, **kwargs):\n+        calls.append((url, kwargs.get(\"json\")))\n+        r = responses[idx[0]] if idx[0] < len(responses) else responses[-1]\n+        idx[0] += 1\n+        ctx = MagicMock()\n+        ctx.__aenter__ = AsyncMock(return_value=r)\n+        ctx.__aexit__ = AsyncMock(return_value=False)\n+        return ctx\n+\n+    session = MagicMock()\n+    session.post = MagicMock(side_effect=_post)\n+    session_ctx = MagicMock()\n+    session_ctx.__aenter__ = AsyncMock(return_value=session)\n+    session_ctx.__aexit__ = AsyncMock(return_value=False)\n+    return session_ctx, calls\n+\n+\n+def _pconfig():\n+    return SimpleNamespace(token=\"\", extra={\"bridge_port\": 3000})\n+\n+\n+def _tmpfile(suffix):\n+    f = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)\n+    f.write(b\"x\")\n+    f.close()\n+    return f.name\n+\n+\n+def test_text_plus_mixed_media_routes_native_types():\n+    img = _tmpfile(\".png\")\n+    vid = _tmpfile(\".mp4\")\n+    voice = _tmpfile(\".ogg\")\n+    try:\n+        session_ctx, calls = _session_with(\n+            [\n+                _resp(200, {\"messageId\": \"t1\"}),\n+                _resp(200, {\"messageId\": \"m1\"}),\n+                _resp(200, {\"messageId\": \"m2\"}),\n+                _resp(200, {\"messageId\": \"m3\"}),\n+            ]\n+        )\n+        with patch(\"aiohttp.ClientSession\", return_value=session_ctx):\n+            res = asyncio.run(\n+                _standalone_send(\n+                    _pconfig(),\n+                    \"12345\",\n+                    \"hello\",\n+                    media_files=[(img, False), (vid, False), (voice, True)],\n+                )\n+            )\n+        assert res[\"success\"] is True\n+        # text first, then three media uploads in order\n+        assert calls[0][0].endswith(\"/send\")\n+        assert calls[0][1][\"message\"] == \"hello\"\n+        media_types = [c[1][\"mediaType\"] for c in calls if c[0].endswith(\"/send-media\")]\n+        assert media_types == [\"image\", \"video\", \"audio\"]\n+        # chat id normalized to a WhatsApp JID\n+        assert \"@\" in calls[0][1][\"chatId\"]\n+    finally:\n+        for p in (img, vid, voice):\n+            os.unlink(p)\n+\n+\n+def test_media_only_skips_text_send():\n+    img = _tmpfile(\".jpg\")\n+    try:\n+        session_ctx, calls = _session_with([_resp(200, {\"messageId\": \"m1\"})])\n+        with patch(\"aiohttp.ClientSession\", return_value=session_ctx):\n+            res = asyncio.run(\n+                _standalone_send(_pconfig(), \"12345\", \"\", media_files=[(img, False)])\n+            )\n+        assert res[\"success\"] is True\n+        assert all(c[0].endswith(\"/send-media\") for c in calls)\n+    finally:\n+        os.unlink(img)\n+\n+\n+def test_force_document_sends_image_as_document():\n+    img = _tmpfile(\".png\")\n+    try:\n+        session_ctx, calls = _session_with(\n+            [_resp(200, {\"messageId\": \"t1\"}), _resp(200, {\"messageId\": \"m1\"})]\n+        )\n+        with patch(\"aiohttp.ClientSession\", return_value=session_ctx):\n+            res = asyncio.run(\n+                _standalone_send(\n+                    _pconfig(),\n+                    \"12345\",\n+                    \"doc\",\n+                    media_files=[(img, False)],\n+                    force_document=True,\n+                )\n+            )\n+        assert res[\"success\"] is True\n+        media_call = [c for c in calls if c[0].endswith(\"/send-media\")][0]\n+        assert media_call[1][\"mediaType\"] == \"document\"\n+        assert media_call[1][\"fileName\"] == os.path.basename(img)\n+    finally:\n+        os.unlink(img)\n+\n+\n+def test_missing_media_file_errors():\n+    session_ctx, _ = _session_with([_resp(200, {\"messageId\": \"t1\"})])\n+    with patch(\"aiohttp.ClientSession\", return_value=session_ctx):\n+        res = asyncio.run(\n+            _standalone_send(\n+                _pconfig(),\n+                \"12345\",\n+                \"hi\",\n+                media_files=[(\"/no/such/file.png\", False)],\n+            )\n+        )\n+    assert \"error\" in res\n+    assert \"not found\" in res[\"error\"]\n+\n+\n+def test_media_upload_error_propagates():\n+    img = _tmpfile(\".png\")\n+    try:\n+        session_ctx, _ = _session_with(\n+            [\n+                _resp(200, {\"messageId\": \"t1\"}),\n+                _resp(500, text_data=\"boom\"),\n+            ]\n+        )\n+        with patch(\"aiohttp.ClientSession\", return_value=session_ctx):\n+            res = asyncio.run(\n+                _standalone_send(\n+                    _pconfig(), \"12345\", \"hi\", media_files=[(img, False)]\n+                )\n+            )\n+        assert \"error\" in res\n+        assert \"500\" in res[\"error\"]\n+    finally:\n+        os.unlink(img)\n+\n+\n+def test_text_only_unchanged_behavior():\n+    session_ctx, calls = _session_with([_resp(200, {\"messageId\": \"t1\"})])\n+    with patch(\"aiohttp.ClientSession\", return_value=session_ctx):\n+        res = asyncio.run(_standalone_send(_pconfig(), \"12345\", \"just text\"))\n+    assert res == {\n+        \"success\": True,\n+        \"platform\": \"whatsapp\",\n+        \"chat_id\": calls[0][1][\"chatId\"],\n+        \"message_id\": \"t1\",\n+    }\n+    assert len(calls) == 1 and calls[0][0].endswith(\"/send\")"]},{"file":"tools/send_message_tool.py","hunks":["@@ -903,11 +903,38 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,\n             last_result = result\n         return last_result\n \n+    # --- WhatsApp: native media attachment support via the registry's\n+    # standalone_sender_fn (plugins/platforms/whatsapp/adapter.py::_standalone_send).\n+    # The plugin uploads each file through the local Baileys bridge /send-media\n+    # endpoint so images/videos/audio arrive as native bubbles, not documents. #41112\n+    if platform == Platform.WHATSAPP and media_files:\n+        from gateway.platform_registry import platform_registry as _pr_wa\n+        from hermes_cli.plugins import discover_plugins as _dp_wa\n+        _dp_wa()\n+        _wa_entry = _pr_wa.get(\"whatsapp\")\n+        if _wa_entry is None or _wa_entry.standalone_sender_fn is None:\n+            return {\"error\": \"WhatsApp plugin not registered or missing standalone_sender_fn\"}\n+        last_result = None\n+        for i, chunk in enumerate(chunks):\n+            is_last = (i == len(chunks) - 1)\n+            result = await _wa_entry.standalone_sender_fn(\n+                pconfig,\n+                chat_id,\n+                chunk,\n+                media_files=media_files if is_last else None,\n+                thread_id=thread_id,\n+                force_document=force_document,\n+            )\n+            if isinstance(result, dict) and result.get(\"error\"):\n+                return result\n+            last_result = result\n+        return last_result\n+\n     # --- Non-media platforms ---\n     if media_files and not message.strip():\n         return {\n             \"error\": (\n-                f\"send_message MEDIA delivery is currently only supported for telegram, discord, matrix, weixin, signal, yuanbao and feishu; \"\n+                f\"send_message MEDIA delivery is currently only supported for telegram, discord, matrix, weixin, signal, yuanbao, feishu and whatsapp; \"\n                 f\"target {platform.value} had only media attachments\"\n             )\n         }","@@ -915,7 +942,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,\n     if media_files:\n         warning = (\n             f\"MEDIA attachments were omitted for {platform.value}; \"\n-            \"native send_message media delivery is currently only supported for telegram, discord, matrix, weixin, signal, yuanbao and feishu\"\n+            \"native send_message media delivery is currently only supported for telegram, discord, matrix, weixin, signal, yuanbao, feishu and whatsapp\"\n         )\n \n     last_result = None\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}