from __future__ import annotations import asyncio import sys import types from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest from nanobot.bus.events import OutboundMessage from nanobot.channels import whatsapp as whatsapp_module from nanobot.channels.whatsapp import WhatsAppChannel, _legacy_bridge_config_fields, _NeonizeAPI class _Proto: def __init__(self, **kwargs): self.__dict__.update(kwargs) def HasField(self, name: str) -> bool: # noqa: N802 - protobuf compatibility return _is_set(getattr(self, name, None)) def ListFields(self): # noqa: N802 - protobuf compatibility return [ (SimpleNamespace(name=name), value) for name, value in self.__dict__.items() if _is_set(value) ] def _is_set(value) -> bool: if value is None: return False if isinstance(value, (str, bytes, list, tuple, dict, set)): return bool(value) return True def _jid(user: str, server: str) -> _Proto: return _Proto(User=user, Server=server, IsEmpty=False) def _event( *, message: _Proto, message_id: str = "m1", chat: _Proto | None = None, sender: _Proto | None = None, sender_alt: _Proto | None = None, is_group: bool = False, timestamp: int = 1, is_from_me: bool = False, ) -> _Proto: source = _Proto( Chat=chat or _jid("15551234567", "s.whatsapp.net"), Sender=sender, SenderAlt=sender_alt, IsGroup=is_group, IsFromMe=is_from_me, ) return _Proto( Info=_Proto(ID=message_id, Timestamp=timestamp, MessageSource=source), Message=message, ) def _make_channel(config: dict | None = None) -> WhatsAppChannel: merged = {"enabled": True, "allowFrom": ["*"]} if config: merged.update(config) ch = WhatsAppChannel(merged, MagicMock()) ch._started_at = 0 return ch def _patch_neonize_api(monkeypatch) -> None: monkeypatch.setattr( whatsapp_module, "_NEONIZE_API", _NeonizeAPI( NewAClient=object, ConnectedEv=object(), DisconnectedEv=object(), MessageEv=object(), PairStatusEv=object(), build_jid=lambda user, server="s.whatsapp.net": (user, server), ), ) def _patch_receipt_type(monkeypatch): neonize = types.ModuleType("neonize") utils = types.ModuleType("neonize.utils") enum = types.ModuleType("neonize.utils.enum") class ReceiptType: READ = "read" enum.ReceiptType = ReceiptType neonize.utils = utils utils.enum = enum monkeypatch.setitem(sys.modules, "neonize", neonize) monkeypatch.setitem(sys.modules, "neonize.utils", utils) monkeypatch.setitem(sys.modules, "neonize.utils.enum", enum) return ReceiptType class _FakeLoginClient: def __init__(self) -> None: self.handlers = {} self.me = _Proto(JID=_jid("bot", "s.whatsapp.net"), LID=_jid("BOTLID", "lid")) self.stop = AsyncMock() def event(self, event_type): def register(func): self.handlers[event_type] = func return func return register def qr(self, func): self.qr_handler = func return func async def connect(self) -> None: await self.handlers[whatsapp_module._NEONIZE_API.ConnectedEv](self, _Proto()) class _FailingConnectLoginClient(_FakeLoginClient): async def connect(self) -> asyncio.Task[None]: async def fail() -> None: raise RuntimeError("dial failed") return asyncio.create_task(fail()) def test_default_config_has_no_bridge_fields() -> None: config = WhatsAppChannel.default_config() assert "bridgeUrl" not in config assert "bridgeToken" not in config assert config["databasePath"] == "" def test_legacy_bridge_config_fields_are_detected() -> None: assert _legacy_bridge_config_fields({"bridgeUrl": "ws://localhost:3001"}) == ["bridgeUrl"] assert _legacy_bridge_config_fields({"bridgeToken": "secret"}) == ["bridgeToken"] @pytest.mark.asyncio async def test_login_succeeds_when_connected(monkeypatch) -> None: _patch_neonize_api(monkeypatch) client = _FakeLoginClient() ch = _make_channel() ch._new_client = MagicMock(return_value=client) assert await ch.login() is True assert ch._self_jids == {"bot@s.whatsapp.net", "bot", "BOTLID@lid", "BOTLID"} client.stop.assert_awaited_once() @pytest.mark.asyncio async def test_login_fails_when_connect_task_fails(monkeypatch) -> None: _patch_neonize_api(monkeypatch) client = _FailingConnectLoginClient() ch = _make_channel() ch._new_client = MagicMock(return_value=client) assert await ch.login() is False client.stop.assert_awaited_once() @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(), ) ch = _make_channel() ch._client = client ch._connected = True await ch.send(OutboundMessage(channel="whatsapp", chat_id="12345@s.whatsapp.net", content="hi")) client.send_message.assert_awaited_once_with(("12345", "s.whatsapp.net"), "hi") @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(), ) ch = _make_channel() ch._client = client ch._connected = True await ch.send( OutboundMessage( channel="whatsapp", chat_id="12345@s.whatsapp.net", content="", media=["photo.jpg", "clip.mp4", "voice.ogg", "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_document.assert_awaited_once_with( jid, "report.pdf", filename="report.pdf", mimetype="application/pdf", ) @pytest.mark.asyncio async def test_send_when_disconnected_raises() -> None: ch = _make_channel() with pytest.raises(RuntimeError, match="not connected"): await ch.send(OutboundMessage(channel="whatsapp", chat_id="123", content="hi")) @pytest.mark.asyncio async def test_group_policy_mention_skips_unmentioned_group_message() -> None: ch = _make_channel({"groupPolicy": "mention"}) ch._self_jids = {"bot@s.whatsapp.net", "bot"} ch._handle_message = AsyncMock() await ch._handle_neonize_message( SimpleNamespace(download_any=AsyncMock()), _event( message=_Proto(conversation="hello group"), chat=_jid("120363000", "g.us"), sender=_jid("SENDERLID", "lid"), is_group=True, ), ) ch._handle_message.assert_not_called() @pytest.mark.asyncio async def test_group_policy_mention_accepts_mention_and_prefers_phone_sender() -> None: ch = _make_channel({"groupPolicy": "mention"}) ch._self_jids = {"bot@s.whatsapp.net", "bot"} ch._handle_message = AsyncMock() context = _Proto(mentionedJID=["bot@s.whatsapp.net"]) message = _Proto(extendedTextMessage=_Proto(text="hello @bot", contextInfo=context)) await ch._handle_neonize_message( SimpleNamespace(download_any=AsyncMock()), _event( message=message, chat=_jid("120363000", "g.us"), sender=_jid("LID99", "lid"), sender_alt=_jid("15559998888", "s.whatsapp.net"), is_group=True, ), ) kwargs = ch._handle_message.await_args.kwargs assert kwargs["sender_id"] == "15559998888" assert kwargs["chat_id"] == "120363000@g.us" assert kwargs["metadata"]["lid"] == "LID99" assert kwargs["metadata"]["phone"] == "15559998888" @pytest.mark.asyncio async def test_group_policy_mention_accepts_reply_to_bot() -> None: ch = _make_channel({"groupPolicy": "mention"}) ch._self_jids = {"bot@s.whatsapp.net", "bot"} ch._handle_message = AsyncMock() context = _Proto(participant="bot@s.whatsapp.net") message = _Proto(extendedTextMessage=_Proto(text="reply", contextInfo=context)) await ch._handle_neonize_message( SimpleNamespace(download_any=AsyncMock()), _event( message=message, chat=_jid("120363000", "g.us"), sender=_jid("SENDERLID", "lid"), is_group=True, ), ) kwargs = ch._handle_message.await_args.kwargs assert kwargs["metadata"]["is_reply_to_bot"] is True @pytest.mark.asyncio async def test_group_sender_id_uses_participant_not_group_jid() -> None: ch = WhatsAppChannel({"enabled": True, "allowFrom": ["SENDERLID"]}, MagicMock()) ch._started_at = 0 ch._handle_message = AsyncMock() await ch._handle_neonize_message( SimpleNamespace(download_any=AsyncMock()), _event( message=_Proto(conversation="hi"), chat=_jid("120363000", "g.us"), sender=_jid("SENDERLID", "lid"), is_group=True, ), ) kwargs = ch._handle_message.await_args.kwargs assert kwargs["sender_id"] == "SENDERLID" assert kwargs["metadata"]["participant"] == "SENDERLID@lid" @pytest.mark.asyncio async def test_read_receipt_is_requested_once_after_dedup() -> None: ch = _make_channel() ch._send_read_receipt = AsyncMock() ch._handle_message = AsyncMock() client = SimpleNamespace(download_any=AsyncMock()) event = _event( message=_Proto(conversation="hi"), sender=_jid("15551234567", "s.whatsapp.net"), ) await ch._handle_neonize_message(client, event) await ch._handle_neonize_message(client, event) ch._send_read_receipt.assert_awaited_once_with( client, event.Info.MessageSource, "m1", ) ch._handle_message.assert_awaited_once() @pytest.mark.asyncio async def test_send_read_receipt_uses_mark_read_and_swallows_failures(monkeypatch) -> None: receipt_type = _patch_receipt_type(monkeypatch) ch = _make_channel() source = _event( message=_Proto(conversation="hi"), sender=_jid("15551234567", "s.whatsapp.net"), ).Info.MessageSource client = SimpleNamespace( mark_read=AsyncMock(), download_any=AsyncMock(), ) await ch._send_read_receipt(client, source, "m1") client.mark_read.assert_awaited_once_with( "m1", chat=source.Chat, sender=source.Sender, receipt=receipt_type.READ, ) failing_client = SimpleNamespace( mark_read=AsyncMock(side_effect=RuntimeError("boom")), download_any=AsyncMock(), ) await ch._send_read_receipt(failing_client, source, "m2") failing_client.mark_read.assert_awaited_once() @pytest.mark.asyncio async def test_lid_to_phone_cache_resolves_lid_only_messages() -> None: ch = _make_channel() ch._handle_message = AsyncMock() await ch._handle_neonize_message( SimpleNamespace(download_any=AsyncMock()), _event( message=_Proto(conversation="first"), message_id="c1", chat=_jid("LID99", "lid"), sender=_jid("LID99", "lid"), sender_alt=_jid("5559999", "s.whatsapp.net"), ), ) await ch._handle_neonize_message( SimpleNamespace(download_any=AsyncMock()), _event( message=_Proto(conversation="second"), message_id="c2", chat=_jid("LID99", "lid"), sender=_jid("LID99", "lid"), ), ) assert ch._handle_message.await_args_list[1].kwargs["sender_id"] == "5559999" def test_lid_mappings_from_config() -> None: ch = WhatsAppChannel( {"enabled": True, "lidMappings": {"123456789012345": "15551234567"}}, MagicMock(), ) assert ch._lid_to_phone == {"123456789012345": "15551234567"} @pytest.mark.asyncio async def test_image_media_is_downloaded_and_forwarded(monkeypatch, tmp_path) -> None: monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel) ch = _make_channel() ch._handle_message = AsyncMock() client = SimpleNamespace(download_any=AsyncMock()) message = _Proto( imageMessage=_Proto( caption="look", mimetype="image/jpeg", ) ) await ch._handle_neonize_message( client, _event(message=message, sender_alt=_jid("15551234567", "s.whatsapp.net")), ) client.download_any.assert_awaited_once() kwargs = ch._handle_message.await_args.kwargs assert kwargs["content"].startswith("look\n[image: ") assert len(kwargs["media"]) == 1 assert kwargs["media"][0].endswith(".jpg") @pytest.mark.asyncio async def test_voice_message_transcribes_and_drops_media_when_successful( monkeypatch, tmp_path ) -> None: monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel) ch = _make_channel() ch._handle_message = AsyncMock() ch.transcribe_audio = AsyncMock(return_value="Hello from audio") client = SimpleNamespace(download_any=AsyncMock()) message = _Proto(audioMessage=_Proto(mimetype="audio/ogg", PTT=True)) await ch._handle_neonize_message( client, _event(message=message, sender_alt=_jid("15551234567", "s.whatsapp.net")), ) ch.transcribe_audio.assert_awaited_once() kwargs = ch._handle_message.await_args.kwargs assert kwargs["content"] == "Hello from audio" assert kwargs["media"] == [] @pytest.mark.asyncio async def test_unauthorized_voice_message_does_not_download_or_transcribe( monkeypatch, tmp_path ) -> None: monkeypatch.setattr(whatsapp_module, "get_media_dir", lambda channel: tmp_path / channel) ch = WhatsAppChannel({"enabled": True, "allowFrom": ["allowed"]}, MagicMock()) ch._started_at = 0 ch._handle_message = AsyncMock() ch.transcribe_audio = AsyncMock(return_value="blocked audio") client = SimpleNamespace(download_any=AsyncMock()) await ch._handle_neonize_message( client, _event( message=_Proto(audioMessage=_Proto(mimetype="audio/ogg", PTT=True)), chat=_jid("blocked", "s.whatsapp.net"), sender=_jid("blocked", "s.whatsapp.net"), ), ) client.download_any.assert_not_awaited() ch.transcribe_audio.assert_not_awaited() ch._handle_message.assert_awaited_once() kwargs = ch._handle_message.await_args.kwargs assert kwargs["sender_id"] == "blocked" assert kwargs["content"] == "" assert kwargs["media"] == [] assert kwargs["is_dm"] is True @pytest.mark.asyncio async def test_unauthorized_dm_uses_base_pairing_flow(monkeypatch) -> None: _patch_neonize_api(monkeypatch) monkeypatch.setattr("nanobot.channels.base.generate_code", lambda _ch, _sid: "ABCD-EFGH") monkeypatch.setattr("nanobot.channels.base.is_approved", lambda _ch, _sid: False) client = SimpleNamespace(send_message=AsyncMock(), download_any=AsyncMock()) ch = WhatsAppChannel({"enabled": True, "allowFrom": []}, MagicMock()) ch._client = client ch._connected = True ch._started_at = 0 await ch._handle_neonize_message( client, _event( message=_Proto(conversation="hello"), chat=_jid("blocked", "s.whatsapp.net"), sender=_jid("blocked", "s.whatsapp.net"), ), ) client.download_any.assert_not_awaited() client.send_message.assert_awaited_once() assert client.send_message.await_args.args[0] == ("blocked", "s.whatsapp.net") assert "ABCD-EFGH" in client.send_message.await_args.args[1] def test_reset_database_removes_sqlite_sidecars(tmp_path) -> None: db = tmp_path / "neonize.db" wal = tmp_path / "neonize.db-wal" shm = tmp_path / "neonize.db-shm" for path in (db, wal, shm): path.write_text("x", encoding="utf-8") WhatsAppChannel._reset_database(db) assert not db.exists() assert not wal.exists() assert not shm.exists()