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.bus.queue import MessageBus 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.parametrize("allowed_group", ["120363000@g.us", "120363000"]) @pytest.mark.asyncio async def test_group_allow_from_accepts_group_jid_or_bare_id(allowed_group: str) -> None: bus = MessageBus() ch = WhatsAppChannel({"enabled": True, "allowFrom": [allowed_group]}, bus) ch._started_at = 0 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, ), ) assert bus.inbound_size == 1 msg = await bus.consume_inbound() assert msg.sender_id == "SENDERLID" assert msg.chat_id == "120363000@g.us" assert msg.content == "hi" assert msg.metadata["participant"] == "SENDERLID@lid" @pytest.mark.asyncio async def test_group_allow_from_does_not_allow_same_participant_in_other_group() -> None: bus = MessageBus() ch = WhatsAppChannel({"enabled": True, "allowFrom": ["120363000"]}, bus) ch._started_at = 0 await ch._handle_neonize_message( SimpleNamespace(download_any=AsyncMock()), _event( message=_Proto(conversation="hi"), chat=_jid("120363999", "g.us"), sender=_jid("SENDERLID", "lid"), is_group=True, ), ) assert bus.inbound_size == 0 @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()