mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 01:18:45 +00:00
fix(whatsapp): detect outbound media content before dispatch (#5203)
This commit is contained in:
parent
9098ffd38f
commit
5a1ab44baa
@ -12,7 +12,9 @@ from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, NamedTuple, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from pydantic import Field
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
@ -20,6 +22,7 @@ from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.config.paths import get_media_dir, get_runtime_subdir
|
||||
from nanobot.config.schema import Base
|
||||
from nanobot.security.network import PinnedDNSAsyncTransport
|
||||
|
||||
|
||||
class WhatsAppConfig(Base):
|
||||
@ -39,6 +42,8 @@ class _NeonizeAPI(NamedTuple):
|
||||
MessageEv: Any
|
||||
PairStatusEv: Any
|
||||
build_jid: Any
|
||||
detect_mime: Any
|
||||
detect_buffer: Any
|
||||
|
||||
|
||||
class _MediaInfo(NamedTuple):
|
||||
@ -52,6 +57,15 @@ class _MediaInfo(NamedTuple):
|
||||
_NEONIZE_API: _NeonizeAPI | None = None
|
||||
_JID_RE = re.compile(r"^(?P<user>[^@]+)@(?P<server>[^@]+)$")
|
||||
_LEGACY_BRIDGE_CONFIG_FIELDS = ("bridgeUrl", "bridgeToken", "bridge_url", "bridge_token")
|
||||
_REMOTE_MEDIA_MAX_BYTES = 32 * 1024 * 1024
|
||||
_REMOTE_MEDIA_MAX_REDIRECTS = 5
|
||||
_REMOTE_MEDIA_TIMEOUT_SECONDS = 120.0
|
||||
# OGG is intentionally excluded: WhatsApp accepts only mono Opus, which MIME sniffing cannot prove.
|
||||
_DIRECT_AUDIO_MIMETYPES = {"audio/aac", "audio/amr", "audio/mp4", "audio/mpeg"}
|
||||
_MIMETYPE_ALIASES = {
|
||||
"audio/x-hx-aac-adts": "audio/aac",
|
||||
"audio/x-m4a": "audio/mp4",
|
||||
}
|
||||
|
||||
|
||||
def _default_database_path() -> Path:
|
||||
@ -68,9 +82,15 @@ def _load_neonize() -> _NeonizeAPI:
|
||||
return _NEONIZE_API
|
||||
|
||||
try:
|
||||
import magic
|
||||
from neonize.aioze.client import NewAClient
|
||||
from neonize.aioze.events import ConnectedEv, DisconnectedEv, MessageEv, PairStatusEv
|
||||
from neonize.utils.jid import build_jid
|
||||
|
||||
detect_mime = getattr(magic, "from_file", None)
|
||||
detect_buffer = getattr(magic, "from_buffer", None)
|
||||
if not callable(detect_mime) or not callable(detect_buffer):
|
||||
raise ImportError("python-magic does not expose from_file/from_buffer")
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"WhatsApp dependencies not installed. Run: nanobot plugins enable whatsapp"
|
||||
@ -83,6 +103,8 @@ def _load_neonize() -> _NeonizeAPI:
|
||||
MessageEv=MessageEv,
|
||||
PairStatusEv=PairStatusEv,
|
||||
build_jid=build_jid,
|
||||
detect_mime=detect_mime,
|
||||
detect_buffer=detect_buffer,
|
||||
)
|
||||
return _NEONIZE_API
|
||||
|
||||
@ -417,23 +439,84 @@ class WhatsAppChannel(BaseChannel):
|
||||
return api.build_jid(user, server)
|
||||
|
||||
async def _send_media(self, client: Any, to: Any, media_path: str) -> None:
|
||||
path = str(Path(media_path).expanduser())
|
||||
mime, _ = mimetypes.guess_type(path)
|
||||
mimetype = mime or "application/octet-stream"
|
||||
source: str | bytes
|
||||
if media_path.startswith(("http://", "https://")):
|
||||
source = await self._fetch_remote_media(media_path)
|
||||
filename = Path(urlparse(media_path).path).name or "attachment"
|
||||
else:
|
||||
source = str(Path(media_path).expanduser())
|
||||
filename = Path(source).name
|
||||
|
||||
mimetype = self._detect_mimetype(source)
|
||||
if mimetype.startswith("image/"):
|
||||
await client.send_image(to, path)
|
||||
await client.send_image(to, source)
|
||||
elif mimetype.startswith("video/"):
|
||||
await client.send_video(to, path)
|
||||
elif mimetype.startswith("audio/"):
|
||||
await client.send_audio(to, path)
|
||||
await client.send_video(to, source)
|
||||
elif mimetype in _DIRECT_AUDIO_MIMETYPES:
|
||||
await client.send_audio(to, source)
|
||||
else:
|
||||
await client.send_document(
|
||||
to,
|
||||
path,
|
||||
filename=Path(path).name,
|
||||
source,
|
||||
filename=filename,
|
||||
mimetype=mimetype,
|
||||
)
|
||||
|
||||
async def _fetch_remote_media(self, url: str) -> bytes:
|
||||
timeout = httpx.Timeout(_REMOTE_MEDIA_TIMEOUT_SECONDS, connect=10.0)
|
||||
async with httpx.AsyncClient(
|
||||
transport=PinnedDNSAsyncTransport(),
|
||||
follow_redirects=True,
|
||||
max_redirects=_REMOTE_MEDIA_MAX_REDIRECTS,
|
||||
timeout=timeout,
|
||||
trust_env=False,
|
||||
) as http:
|
||||
async with http.stream("GET", url) as response:
|
||||
response.raise_for_status()
|
||||
declared_size = response.headers.get("content-length")
|
||||
if (
|
||||
declared_size
|
||||
and declared_size.isdigit()
|
||||
and int(declared_size) > _REMOTE_MEDIA_MAX_BYTES
|
||||
):
|
||||
raise ValueError(
|
||||
f"Remote WhatsApp media exceeds the {_REMOTE_MEDIA_MAX_BYTES}-byte limit"
|
||||
)
|
||||
|
||||
chunks: list[bytes] = []
|
||||
total = 0
|
||||
async for chunk in response.aiter_bytes():
|
||||
total += len(chunk)
|
||||
if total > _REMOTE_MEDIA_MAX_BYTES:
|
||||
raise ValueError(
|
||||
f"Remote WhatsApp media exceeds the {_REMOTE_MEDIA_MAX_BYTES}-byte limit"
|
||||
)
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
def _detect_mimetype(self, source: str | bytes) -> str:
|
||||
try:
|
||||
api = _load_neonize()
|
||||
detected = (
|
||||
api.detect_buffer(source, mime=True)
|
||||
if isinstance(source, bytes)
|
||||
else api.detect_mime(source, mime=True)
|
||||
)
|
||||
except Exception as exc:
|
||||
label = f"{len(source)} downloaded bytes" if isinstance(source, bytes) else source
|
||||
self.logger.debug("Failed to inspect WhatsApp media {}: {}", label, exc)
|
||||
detected = None
|
||||
|
||||
if isinstance(detected, str) and "/" in detected:
|
||||
mimetype = detected.partition(";")[0].strip().lower()
|
||||
return _MIMETYPE_ALIASES.get(mimetype, mimetype)
|
||||
|
||||
if isinstance(source, bytes):
|
||||
return "application/octet-stream"
|
||||
|
||||
guessed, _ = mimetypes.guess_type(source)
|
||||
return guessed or "application/octet-stream"
|
||||
|
||||
def _register_handlers(
|
||||
self,
|
||||
client: Any,
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import mimetypes
|
||||
import sys
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import nanobot.channels.whatsapp.runtime as whatsapp_module
|
||||
@ -78,7 +80,21 @@ def _make_channel(config: dict | None = None) -> WhatsAppChannel:
|
||||
return ch
|
||||
|
||||
|
||||
def _patch_neonize_api(monkeypatch) -> None:
|
||||
def _make_send_client() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
send_message=AsyncMock(),
|
||||
send_image=AsyncMock(),
|
||||
send_video=AsyncMock(),
|
||||
send_audio=AsyncMock(),
|
||||
send_document=AsyncMock(),
|
||||
)
|
||||
|
||||
|
||||
def _patch_neonize_api(monkeypatch, detect_mime=None, detect_buffer=None) -> None:
|
||||
detect_mime = detect_mime or (
|
||||
lambda path, *, mime: mimetypes.guess_type(path)[0] or "application/octet-stream"
|
||||
)
|
||||
detect_buffer = detect_buffer or (lambda data, *, mime: "application/octet-stream")
|
||||
monkeypatch.setattr(
|
||||
whatsapp_module,
|
||||
"_NEONIZE_API",
|
||||
@ -89,6 +105,8 @@ def _patch_neonize_api(monkeypatch) -> None:
|
||||
MessageEv=object(),
|
||||
PairStatusEv=object(),
|
||||
build_jid=lambda user, server="s.whatsapp.net": (user, server),
|
||||
detect_mime=detect_mime,
|
||||
detect_buffer=detect_buffer,
|
||||
),
|
||||
)
|
||||
|
||||
@ -178,13 +196,7 @@ async def test_login_fails_when_connect_task_fails(monkeypatch) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_text_uses_neonize_send_message(monkeypatch) -> None:
|
||||
_patch_neonize_api(monkeypatch)
|
||||
client = SimpleNamespace(
|
||||
send_message=AsyncMock(),
|
||||
send_image=AsyncMock(),
|
||||
send_video=AsyncMock(),
|
||||
send_audio=AsyncMock(),
|
||||
send_document=AsyncMock(),
|
||||
)
|
||||
client = _make_send_client()
|
||||
ch = _make_channel()
|
||||
ch._client = client
|
||||
ch._connected = True
|
||||
@ -197,13 +209,7 @@ async def test_send_text_uses_neonize_send_message(monkeypatch) -> None:
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None:
|
||||
_patch_neonize_api(monkeypatch)
|
||||
client = SimpleNamespace(
|
||||
send_message=AsyncMock(),
|
||||
send_image=AsyncMock(),
|
||||
send_video=AsyncMock(),
|
||||
send_audio=AsyncMock(),
|
||||
send_document=AsyncMock(),
|
||||
)
|
||||
client = _make_send_client()
|
||||
ch = _make_channel()
|
||||
ch._client = client
|
||||
ch._connected = True
|
||||
@ -213,14 +219,14 @@ async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None:
|
||||
channel="whatsapp",
|
||||
chat_id="12345@s.whatsapp.net",
|
||||
content="",
|
||||
media=["photo.jpg", "clip.mp4", "voice.ogg", "report.pdf"],
|
||||
media=["photo.jpg", "clip.mp4", "voice.mp3", "report.pdf"],
|
||||
)
|
||||
)
|
||||
|
||||
jid = ("12345", "s.whatsapp.net")
|
||||
client.send_image.assert_awaited_once_with(jid, "photo.jpg")
|
||||
client.send_video.assert_awaited_once_with(jid, "clip.mp4")
|
||||
client.send_audio.assert_awaited_once_with(jid, "voice.ogg")
|
||||
client.send_audio.assert_awaited_once_with(jid, "voice.mp3")
|
||||
client.send_document.assert_awaited_once_with(
|
||||
jid,
|
||||
"report.pdf",
|
||||
@ -229,6 +235,191 @@ async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None:
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_mislabeled_audio_as_document(monkeypatch) -> None:
|
||||
_patch_neonize_api(monkeypatch, detect_mime=lambda path, *, mime: "audio/x-wav")
|
||||
client = _make_send_client()
|
||||
ch = _make_channel()
|
||||
ch._client = client
|
||||
ch._connected = True
|
||||
|
||||
await ch.send(
|
||||
OutboundMessage(
|
||||
channel="whatsapp",
|
||||
chat_id="12345@s.whatsapp.net",
|
||||
content="",
|
||||
media=["recording.mpeg"],
|
||||
)
|
||||
)
|
||||
|
||||
jid = ("12345", "s.whatsapp.net")
|
||||
client.send_document.assert_awaited_once_with(
|
||||
jid,
|
||||
"recording.mpeg",
|
||||
filename="recording.mpeg",
|
||||
mimetype="audio/x-wav",
|
||||
)
|
||||
client.send_video.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_remote_mislabeled_audio_as_document(monkeypatch) -> None:
|
||||
payload = b"remote wav payload"
|
||||
media_url = "https://cdn.example/recording.mpeg?token=secret"
|
||||
|
||||
def handle_request(request: httpx.Request) -> httpx.Response:
|
||||
assert str(request.url) == media_url
|
||||
return httpx.Response(200, content=payload)
|
||||
|
||||
monkeypatch.setattr(
|
||||
whatsapp_module,
|
||||
"PinnedDNSAsyncTransport",
|
||||
lambda: httpx.MockTransport(handle_request),
|
||||
)
|
||||
|
||||
def detect_buffer(data: bytes, *, mime: bool) -> str:
|
||||
assert data == payload
|
||||
assert mime is True
|
||||
return "audio/x-wav"
|
||||
|
||||
_patch_neonize_api(
|
||||
monkeypatch,
|
||||
detect_buffer=detect_buffer,
|
||||
)
|
||||
client = _make_send_client()
|
||||
ch = _make_channel()
|
||||
ch._client = client
|
||||
ch._connected = True
|
||||
|
||||
await ch.send(
|
||||
OutboundMessage(
|
||||
channel="whatsapp",
|
||||
chat_id="12345@s.whatsapp.net",
|
||||
content="",
|
||||
media=[media_url],
|
||||
)
|
||||
)
|
||||
|
||||
jid = ("12345", "s.whatsapp.net")
|
||||
client.send_document.assert_awaited_once_with(
|
||||
jid,
|
||||
payload,
|
||||
filename="recording.mpeg",
|
||||
mimetype="audio/x-wav",
|
||||
)
|
||||
client.send_video.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_remote_media_blocks_private_url(monkeypatch) -> None:
|
||||
_patch_neonize_api(monkeypatch)
|
||||
client = _make_send_client()
|
||||
ch = _make_channel()
|
||||
ch._client = client
|
||||
ch._connected = True
|
||||
|
||||
with pytest.raises(httpx.RequestError, match="private/internal"):
|
||||
await ch.send(
|
||||
OutboundMessage(
|
||||
channel="whatsapp",
|
||||
chat_id="12345@s.whatsapp.net",
|
||||
content="",
|
||||
media=["http://127.0.0.1/recording.mpeg"],
|
||||
)
|
||||
)
|
||||
|
||||
client.send_video.assert_not_awaited()
|
||||
client.send_document.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_remote_media_enforces_download_limit(monkeypatch) -> None:
|
||||
monkeypatch.setattr(whatsapp_module, "_REMOTE_MEDIA_MAX_BYTES", 3)
|
||||
monkeypatch.setattr(
|
||||
whatsapp_module,
|
||||
"PinnedDNSAsyncTransport",
|
||||
lambda: httpx.MockTransport(lambda request: httpx.Response(200, content=b"1234")),
|
||||
)
|
||||
_patch_neonize_api(monkeypatch)
|
||||
client = _make_send_client()
|
||||
ch = _make_channel()
|
||||
ch._client = client
|
||||
ch._connected = True
|
||||
|
||||
with pytest.raises(ValueError, match="exceeds the 3-byte limit"):
|
||||
await ch.send(
|
||||
OutboundMessage(
|
||||
channel="whatsapp",
|
||||
chat_id="12345@s.whatsapp.net",
|
||||
content="",
|
||||
media=["https://cdn.example/recording.mpeg"],
|
||||
)
|
||||
)
|
||||
|
||||
client.send_video.assert_not_awaited()
|
||||
client.send_document.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_unsupported_ogg_audio_as_document(monkeypatch) -> None:
|
||||
_patch_neonize_api(monkeypatch, detect_mime=lambda path, *, mime: "audio/ogg")
|
||||
client = _make_send_client()
|
||||
ch = _make_channel()
|
||||
ch._client = client
|
||||
ch._connected = True
|
||||
|
||||
await ch.send(
|
||||
OutboundMessage(
|
||||
channel="whatsapp",
|
||||
chat_id="12345@s.whatsapp.net",
|
||||
content="",
|
||||
media=["voice.ogg"],
|
||||
)
|
||||
)
|
||||
|
||||
jid = ("12345", "s.whatsapp.net")
|
||||
client.send_document.assert_awaited_once_with(
|
||||
jid,
|
||||
"voice.ogg",
|
||||
filename="voice.ogg",
|
||||
mimetype="audio/ogg",
|
||||
)
|
||||
client.send_audio.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("detected_mimetype", "filename"),
|
||||
[
|
||||
("audio/x-m4a", "recording.m4a"),
|
||||
("audio/x-hx-aac-adts", "recording.aac"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_supported_audio_magic_aliases_inline(
|
||||
monkeypatch, detected_mimetype: str, filename: str
|
||||
) -> None:
|
||||
_patch_neonize_api(
|
||||
monkeypatch,
|
||||
detect_mime=lambda path, *, mime: detected_mimetype,
|
||||
)
|
||||
client = _make_send_client()
|
||||
ch = _make_channel()
|
||||
ch._client = client
|
||||
ch._connected = True
|
||||
|
||||
await ch.send(
|
||||
OutboundMessage(
|
||||
channel="whatsapp",
|
||||
chat_id="12345@s.whatsapp.net",
|
||||
content="",
|
||||
media=[filename],
|
||||
)
|
||||
)
|
||||
|
||||
client.send_audio.assert_awaited_once_with(("12345", "s.whatsapp.net"), filename)
|
||||
client.send_document.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_when_disconnected_raises() -> None:
|
||||
ch = _make_channel()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user