# NousResearch/hermes-agent · WhatsApp media attachment sending via Baileys bridge

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #53598. Verify it addresses your case.
- **Recommended:** read PR #53598 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.50`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #41405: \[Feature\] WhatsApp: send_message media attachment support (bridge already exposes /send-media)

## Prior & competing PRs

- **#41426: feat(whatsapp): add media attachment support via bridge /send-media endpoint**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#32947`, `#41426`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#53598: feat(send_message): native WhatsApp media delivery via Baileys bridge**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `plugins/platforms/whatsapp/adapter.py`, `tests/tools/test_whatsapp_send_message_media.py`, `tools/send_message_tool.py`
  - fix hunks:
  - `plugins/platforms/whatsapp/adapter.py`

    ```diff
    @@ -1293,6 +1293,31 @@ async def _build_message_event(self, data: Dict[str, Any]) -> Optional[MessageEv
     # ──────────────────────────────────────────────────────────────────────────
     
     
    +_WA_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
    +_WA_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp"}
    +_WA_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}
    +
    +
    +def _bridge_media_type(file_path: str, is_voice: bool, force_document: bool) -> str:
    +    """Map a local media file to the bridge /send-media ``mediaType``.
    +
    +    Returns one of ``image`` | ``video`` | ``audio`` | ``document`` so the
    +    Baileys bridge renders the right native WhatsApp message kind. Voice notes
    +    and audio files route to ``audio``; ``force_document`` (the [[as_document]]
    +    directive) forces every file to ``document`` regardless of extension.
    +    """
    +    if force_document:
    +        return "document"
    +    ext = os.path.splitext(file_path)[1].lower()
    +    if is_voice or ext in _WA_AUDIO_EXTS:
    +        return "audio"
    +    if ext in _WA_IMAGE_EXTS:
    +        return "image"
    +    if ext in _WA_VIDEO_EXTS:
    +        return "video"
    +    return "document"
    +
    +
     async def _standalone_send(
         pconfig,
         chat_id,
    ```

    ```diff
    @@ -1316,22 +1341,55 @@ async def _standalone_send(
         try:
             bridge_port = extra.get("bridge_port", 3000)
             normalized_chat_id = to_whatsapp_jid(chat_id)
    +        media = media_files or []
    +        text = message or ""
    +        last_message_id = None
             async with aiohttp.ClientSession() as session:
    -            async with session.post(
    -                f"http://localhost:{bridge_port}/send",
    -                json={"chatId": normalized_chat_id, "message": message},
    -                timeout=aiohttp.ClientTimeout(total=30),
    -            ) as resp:
    -                if resp.status == 200:
    +            # 1) Text first (skip the /send call when this chunk is media-only).
    +            if text.strip():
    +                async with session.post(
    +                    f"http://localhost:{bridge_port}/send",
    +                    json={"chatId": normalized_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()
    -                    return {
    -                        "success": True,
    -                        "platform": "whatsapp",
    -                        "chat_id": normalized_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")
    +
    +            # 2) Each media file as a native attachment via /send-media. The
    +            # bridge maps mediaType -> image/video/audio/document message kinds
    +            # so PNG/JPEG/WebP/GIF arrive as inline images, MP4 as a video
    +            # bubble, and ogg/opus as a voice note — not a file/document.
    +            for media_path, is_voice in media:
    +                if not os.path.exists(media_path):
    +                    return {"error": f"WhatsApp media file not found: {media_path}"}
    +                media_type = _bridge_media_type(media_path, is_voice, force_document)
    +                payload: Dict[str, Any] = {
    +                    "chatId": normalized_chat_id,
    +                    "filePath": media_path,
    +                    "mediaType": media_type,
    +                }
    +                if media_type == "document":
    +                    payload["fileName"] = os.path.basename(media_path)
    +                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 error ({resp.status}): {body}"}
    +                    data = await resp.json()
    +                    last_message_id = data.get("messageId") or last_message_id
    +
    +        return {
    +            "success": True,
    +            "platform": "whatsapp",
    +            "chat_id": normalized_chat_id,
    +            "message_id": last_message_id,
    +        }
         except Exception as e:
             return {"error": f"WhatsApp send failed: {e}"}
     
    ```
  - `tests/tools/test_whatsapp_send_message_media.py`

    ```diff
    @@ -0,0 +1,221 @@
    +"""WhatsApp media delivery for send_message (#19105).
    +
    +Covers two layers:
    +
    +* ``_bridge_media_type`` — extension/voice/force_document -> bridge mediaType.
    +* ``_standalone_send`` — text-first then per-file ``/send-media`` uploads,
    +  media-only (skip ``/send``), and missing-file errors. The bridge HTTP calls
    +  are mocked at the ``aiohttp.ClientSession`` boundary.
    +"""
    +
    +import asyncio
    +import os
    +import tempfile
    +from types import SimpleNamespace
    +from unittest.mock import AsyncMock, MagicMock, patch
    +
    +import pytest
    +
    +from plugins.platforms.whatsapp.adapter import _bridge_media_type, _standalone_send
    +
    +
    +# ---------------------------------------------------------------------------
    +# _bridge_media_type
    +# ---------------------------------------------------------------------------
    +
    +
    [redacted:email](
    +    "path,is_voice,force_document,expected",
    +    [
    +        ("a.png", False, False, "image"),
    +        ("a.JPG", False, False, "image"),
    +        ("a.jpeg", False, False, "image"),
    +        ("a.webp", False, False, "image"),
    +        ("a.gif", False, False, "image"),
    +        ("a.mp4", False, False, "video"),
    +        ("a.mov", False, False, "video"),
    +        ("a.webm", False, False, "video"),
    +        ("a.ogg", True, False, "audio"),
    +        ("a.opus", False, False, "audio"),
    +        ("a.mp3", False, False, "audio"),
    +        ("a.wav", False, False, "audio"),
    +        ("a.pdf", False, False, "document"),
    +        ("a.zip", False, False, "document"),
    +        # force_document overrides everything
    +        ("a.png", False, True, "document"),
    +        ("a.mp4", False, True, "document"),
    +        # is_voice wins over a video extension
    +        ("a.mp4", True, False, "audio"),
    +    ],
    +)
    +def test_bridge_media_type(path, is_voice, force_document, expected):
    +    assert _bridge_media_type(path, is_voice, force_document) == expected
    +
    +
    +# ---------------------------------------------------------------------------
    +# _standalone_send — bridge HTTP mocked
    +# ---------------------------------------------------------------------------
    +
    +
    +def _resp(status, json_data=None, text_data=None):
    +    r = AsyncMock()
    +    r.status = status
    +    r.json = AsyncMock(return_value=json_data or {})
    +    r.text = AsyncMock(return_value=text_data or "")
    +    return r
    +
    +
    +def _session_with(responses):
    +    """Build a mocked aiohttp.ClientSession that returns *responses* in order
    +    and records every POST (url, json_payload)."""
    +    calls = []
    +    idx = [0]
    +
    +    def _post(url, **kwargs):
    +        calls.append((url, kwargs.get("json")))
    +        r = responses[idx[0]] if idx[0] < len(responses) else responses[-1]
    +        idx[0] += 1
    +        ctx = MagicMock()
    +        ctx.__aenter__ = AsyncMock(return_value=r)
    +        ctx.__aexit__ = AsyncMock(return_value=False)
    +        return ctx
    +
    +    session = MagicMock()
    +    session.post = MagicMock(side_effect=_post)
    +    session_ctx = MagicMock()
    +    session_ctx.__aenter__ = AsyncMock(return_value=session)
    +    session_ctx.__aexit__ = AsyncMock(return_value=False)
    +    return session_ctx, calls
    +
    +
    +def _pconfig():
    +    return SimpleNamespace(token="", extra={"bridge_port": 3000})
    +
    +
    +def _tmpfile(suffix):
    +    f = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
    +    f.write(b"x")
    +    f.close()
    +    return f.name
    +
    +
    +def test_text_plus_mixed_media_routes_native_types():
    +    img = _tmpfile(".png")
    +    vid = _tmpfile(".mp4")
    +    voice = _tmpfile(".ogg")
    +    try:
    +        session_ctx, calls = _session_with(
    +            [
    +                _resp(200, {"messageId": "t1"}),
    +                _resp(200, {"messageId": "m1"}),
    +                _resp(200, {"messageId": "m2"}),
    +                _resp(200, {"messageId": "m3"}),
    +            ]
    +        )
    +        with patch("aiohttp.ClientSession", return_value=session_ctx):
    +            res = asyncio.run(
    +                _standalone_send(
    +                    _pconfig(),
    +                    "12345",
    +                    "hello",
    +                    media_files=[(img, False), (vid, False), (voice, True)],
    +                )
    +            )
    +        assert res["success"] is True
    +        # text first, then three media uploads in order
    +        assert calls[0][0].endswith("/send")
    +        assert calls[0][1]["message"] == "hello"
    +        media_types = [c[1]["mediaType"] for c in calls if c[0].endswith("/send-media")]
    +        assert media_types == ["image", "video", "audio"]
    +        # chat id normalized to a WhatsApp JID
    +        assert "@" in calls[0][1]["chatId"]
    +    finally:
    +        for p in (img, vid, voice):
    +            os.unlink(p)
    +
    +
    +def test_media_only_skips_text_send():
    +    img = _tmpfile(".jpg")
    +    try:
    +        session_ctx, calls = _session_with([_resp(200, {"messageId": "m1"})])
    +        with patch("aiohttp.ClientSession", return_value=session_ctx):
    +            res = asyncio.run(
    +                _standalone_send(_pconfig(), "12345", "", media_files=[(img, False)])
    +            )
    +        assert res["success"] is True
    +        assert all(c[0].endswith("/send-media") for c in calls)
    +    finally:
    +        os.unlink(img)
    +
    +
    +def test_force_document_sends_image_as_document():
    +    img = _tmpfile(".png")
    +    try:
    +        session_ctx, calls = _session_with(
    +            [_resp(200, {"messageId": "t1"}), _resp(200, {"messageId": "m1"})]
    +        )
    +        with patch("aiohttp.ClientSession", return_value=session_ctx):
    +            res = asyncio.run(
    +                _standalone_send(
    +                    _pconfig(),
    +                    "12345",
    +                    "doc",
    +                    media_files=[(img, False)],
    +                    force_document=True,
    +                )
    +            )
    +        assert res["success"] is True
    +        media_call = [c for c in calls if c[0].endswith("/send-media")][0]
    +        assert media_call[1]["mediaType"] == "document"
    +        assert media_call[1]["fileName"] == os.path.basename(img)
    +    finally:
    +        os.unlink(img)
    +
    +
    +def test_missing_media_file_errors():
    +    session_ctx, _ = _session_with([_resp(200, {"messageId": "t1"})])
    +    with patch("aiohttp.ClientSession", return_value=session_ctx):
    +        res = asyncio.run(
    +            _standalone_send(
    +                _pconfig(),
    +                "12345",
    +                "hi",
    +                media_files=[("/no/such/file.png", False)],
    +            )
    +        )
    +    assert "error" in res
    +    assert "not found" in res["error"]
    +
    +
    +def test_media_upload_error_propagates():
    +    img = _tmpfile(".png")
    +    try:
    +        session_ctx, _ = _session_with(
    +            [
    +                _resp(200, {"messageId": "t1"}),
    +                _resp(500, text_data="boom"),
    +            ]
    +        )
    +        with patch("aiohttp.ClientSession", return_value=session_ctx):
    +            res = asyncio.run(
    +                _standalone_send(
    +                    _pconfig(), "12345", "hi", media_files=[(img, False)]
    +                )
    +            )
    +        assert "error" in res
    +        assert "500" in res["error"]
    +    finally:
    +        os.unlink(img)
    +
    +
    +def test_text_only_unchanged_behavior():
    +    session_ctx, calls = _session_with([_resp(200, {"messageId": "t1"})])
    +    with patch("aiohttp.ClientSession", return_value=session_ctx):
    +        res = asyncio.run(_standalone_send(_pconfig(), "12345", "just text"))
    +    assert res == {
    +        "success": True,
    +        "platform": "whatsapp",
    +        "chat_id": calls[0][1]["chatId"],
    +        "message_id": "t1",
    +    }
    +    assert len(calls) == 1 and calls[0][0].endswith("/send")
    ```
  - `tools/send_message_tool.py`

    ```diff
    @@ -903,11 +903,38 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None,
                 last_result = result
             return last_result
     
    +    # --- WhatsApp: native media attachment support via the registry's
    +    # standalone_sender_fn (plugins/platforms/whatsapp/adapter.py::_standalone_send).
    +    # The plugin uploads each file through the local Baileys bridge /send-media
    +    # endpoint so images/videos/audio arrive as native bubbles, not documents. #41112
    +    if platform == Platform.WHATSAPP and media_files:
    +        from gateway.platform_registry import platform_registry as _pr_wa
    +        from hermes_cli.plugins import discover_plugins as _dp_wa
    +        _dp_wa()
    +        _wa_entry = _pr_wa.get("whatsapp")
    +        if _wa_entry is None or _wa_entry.standalone_sender_fn is None:
    +            return {"error": "WhatsApp plugin not registered or missing standalone_sender_fn"}
    +        last_result = None
    +        for i, chunk in enumerate(chunks):
    +            is_last = (i == len(chunks) - 1)
    +            result = await _wa_entry.standalone_sender_fn(
    +                pconfig,
    +                chat_id,
    +                chunk,
    +                media_files=media_files if is_last else None,
    +                thread_id=thread_id,
    +                force_document=force_document,
    +            )
    +            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"
                 )
             }
    ```

    ```diff
    @@ -915,7 +942,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=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
    ```
- **#41466: feat(send_message): add WhatsApp media attachment support via Baileys bridge**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `cd592c105cbbc0bbf927b30ee9d061b1dd7b0b1b`, `#53598`, `#41112`

## Fix status

- Merged to main in PR #53598 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-27`.
- Subsystem: `gateway`
