diff --git a/docs/chat-apps.md b/docs/chat-apps.md index 26531e158..f8b4c1f2e 100644 --- a/docs/chat-apps.md +++ b/docs/chat-apps.md @@ -343,6 +343,24 @@ Optional session database path: } ``` +Optional activity cues: + +```json +{ + "channels": { + "whatsapp": { + "typingPresence": true, + "reactEmoji": "👀" + } + } +} +``` + +Set `typingPresence` to `false` to stop sending composing indicators. Set +`reactEmoji` to `""` to disable the temporary reaction while nanobot works. +Outbound WhatsApp messages preserve explicit mention metadata when a tool or +channel sends native WhatsApp mentions. + **Migrating from the old bridge** - Remove `bridgeUrl` and `bridgeToken`; WhatsApp no longer runs a local Node.js bridge. diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 9e7e3e6f1..548ffea87 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -29,6 +29,8 @@ class WhatsAppConfig(Base): group_policy: Literal["open", "mention"] = "open" database_path: str = "" lid_mappings: dict[str, str] = Field(default_factory=dict) + typing_presence: bool = True + react_emoji: str = "👀" class _NeonizeAPI(NamedTuple): @@ -38,6 +40,8 @@ class _NeonizeAPI(NamedTuple): MessageEv: Any PairStatusEv: Any build_jid: Any + ChatPresence: Any + ChatPresenceMedia: Any class _MediaInfo(NamedTuple): @@ -48,6 +52,11 @@ class _MediaInfo(NamedTuple): is_voice: bool = False +class _ReactionTarget(NamedTuple): + message_id: str + sender_jid: str + + _NEONIZE_API: _NeonizeAPI | None = None _JID_RE = re.compile(r"^(?P[^@]+)@(?P[^@]+)$") _LEGACY_BRIDGE_CONFIG_FIELDS = ("bridgeUrl", "bridgeToken", "bridge_url", "bridge_token") @@ -69,6 +78,7 @@ def _load_neonize() -> _NeonizeAPI: try: from neonize.aioze.client import NewAClient from neonize.aioze.events import ConnectedEv, DisconnectedEv, MessageEv, PairStatusEv + from neonize.utils.enum import ChatPresence, ChatPresenceMedia from neonize.utils.jid import build_jid except ImportError as exc: raise RuntimeError( @@ -82,6 +92,8 @@ def _load_neonize() -> _NeonizeAPI: MessageEv=MessageEv, PairStatusEv=PairStatusEv, build_jid=build_jid, + ChatPresence=ChatPresence, + ChatPresenceMedia=ChatPresenceMedia, ) return _NEONIZE_API @@ -176,6 +188,61 @@ def _classify_sender_ids(jids: list[Any]) -> tuple[str, str]: return phone_id, lid_id +def _mention_token(raw: Any) -> tuple[str, bool]: + text = _normalize_jid(raw) + if not text: + return "", False + + is_lid = False + match = _JID_RE.match(text) + if match: + text = match.group("user") + is_lid = match.group("server") in {"lid", "lid.whatsapp.net"} + + token = re.sub(r"\D+", "", text.split(":", 1)[0]) + return token, is_lid + + +def _ghost_mentions_from_metadata(metadata: dict[str, Any]) -> tuple[str | None, bool]: + raw_mentions = ( + metadata.get("mentions") + or metadata.get("mentioned_jids") + or metadata.get("mentionedJids") + or [] + ) + if isinstance(raw_mentions, (str, int)): + raw_mentions = [raw_mentions] + if not isinstance(raw_mentions, list | tuple | set): + return None, False + + phone_tokens: list[str] = [] + lid_tokens: list[str] = [] + seen: set[tuple[bool, str]] = set() + for value in raw_mentions: + if isinstance(value, dict): + value = ( + value.get("jid") + or value.get("id") + or value.get("phone") + or value.get("lid") + or "" + ) + token, is_lid = _mention_token(value) + if not token or (is_lid, token) in seen: + continue + seen.add((is_lid, token)) + if is_lid: + lid_tokens.append(token) + else: + phone_tokens.append(token) + + if phone_tokens: + return " ".join(f"@{token}" for token in phone_tokens), False + if lid_tokens: + return " ".join(f"@{token}" for token in lid_tokens), True + return None, False + + def _context_infos(message: Any) -> list[Any]: infos: list[Any] = [] for container in ( @@ -293,6 +360,8 @@ class WhatsAppChannel(BaseChannel): self._lid_to_phone = self._load_lid_mappings() self._self_jids: set[str] = set() self._started_at = 0.0 + self._typing_tasks: dict[str, asyncio.Task[None]] = {} + self._reaction_targets: dict[str, _ReactionTarget] = {} def _database_path(self) -> Path: configured = self.config.database_path.strip() @@ -359,6 +428,8 @@ class WhatsAppChannel(BaseChannel): async def stop(self) -> None: self._running = False self._connected = False + for chat_id in list(self._typing_tasks): + self._stop_typing(chat_id) client = self._client self._client = None if client is not None: @@ -394,8 +465,20 @@ class WhatsAppChannel(BaseChannel): raise RuntimeError("WhatsApp channel is not connected") to = self._build_jid(msg.chat_id) + if not msg.metadata.get("_progress", False): + await self._finish_activity(msg.chat_id) + if msg.content: - await client.send_message(to, msg.content) + ghost_mentions, mentions_are_lids = _ghost_mentions_from_metadata(msg.metadata) + if ghost_mentions: + await client.send_message( + to, + msg.content, + ghost_mentions=ghost_mentions, + mentions_are_lids=mentions_are_lids, + ) + else: + await client.send_message(to, msg.content) for media_path in msg.media or []: await self._send_media(client, to, media_path) @@ -429,6 +512,91 @@ class WhatsAppChannel(BaseChannel): mimetype=mimetype, ) + def _start_typing(self, chat_id: str) -> None: + if not self.config.typing_presence or not self._client or not self._connected: + return + self._stop_typing(chat_id) + self._typing_tasks[chat_id] = asyncio.create_task(self._typing_loop(chat_id)) + + def _stop_typing(self, chat_id: str) -> bool: + task = self._typing_tasks.pop(chat_id, None) + if not task: + return False + if not task.done(): + task.cancel() + return True + + async def _typing_loop(self, chat_id: str) -> None: + try: + while self._client and self._connected: + await self._send_presence(chat_id, composing=True) + await asyncio.sleep(4) + except asyncio.CancelledError: + pass + except Exception as exc: + self.logger.debug("WhatsApp typing indicator stopped for {}: {}", chat_id, exc) + + async def _send_presence(self, chat_id: str, *, composing: bool) -> None: + client = self._client + if client is None or not self._connected: + return + try: + api = _load_neonize() + state = ( + api.ChatPresence.CHAT_PRESENCE_COMPOSING + if composing + else api.ChatPresence.CHAT_PRESENCE_PAUSED + ) + await client.send_chat_presence( + self._build_jid(chat_id), + state, + api.ChatPresenceMedia.CHAT_PRESENCE_MEDIA_TEXT, + ) + except Exception as exc: + self.logger.debug("WhatsApp presence update failed: {}", exc) + + async def _send_reaction( + self, + chat_id: str, + sender_jid: str, + message_id: str, + emoji: str, + ) -> None: + client = self._client + if client is None or not self._connected or not message_id or not sender_jid: + return + try: + reaction_message = await client.build_reaction( + self._build_jid(chat_id), + self._build_jid(sender_jid), + message_id, + emoji, + ) + await client.send_message(self._build_jid(chat_id), reaction_message) + except Exception as exc: + self.logger.debug("WhatsApp reaction update failed: {}", exc) + + async def _start_activity( + self, + *, + chat_id: str, + message_id: str, + sender_jid: str, + ) -> None: + self._start_typing(chat_id) + if self.config.react_emoji and message_id and sender_jid: + self._reaction_targets[chat_id] = _ReactionTarget(message_id, sender_jid) + await self._send_reaction(chat_id, sender_jid, message_id, self.config.react_emoji) + + async def _finish_activity(self, chat_id: str) -> None: + stopped_typing = self._stop_typing(chat_id) + if stopped_typing: + await self._send_presence(chat_id, composing=False) + + target = self._reaction_targets.pop(chat_id, None) + if target is not None: + await self._send_reaction(chat_id, target.sender_jid, target.message_id, "") + def _register_handlers( self, client: Any, @@ -537,6 +705,7 @@ class WhatsAppChannel(BaseChannel): sender_candidates = [sender_alt_jid, participant_jid] if not is_group: sender_candidates.append(chat_jid) + reaction_sender_jid = sender_alt_jid or participant_jid or chat_jid phone_id, lid_id = _classify_sender_ids(sender_candidates) if phone_id and lid_id: @@ -552,6 +721,7 @@ class WhatsAppChannel(BaseChannel): "is_forwarded": self._is_forwarded(message), "participant": participant_jid or None, "sender_alt": sender_alt_jid or None, + "reaction_sender": reaction_sender_jid or None, "lid": lid_id or None, "phone": phone_id or None, "is_reply_to_bot": self._is_reply_to_bot(message), @@ -594,6 +764,12 @@ class WhatsAppChannel(BaseChannel): if not text and not media_paths: return + await self._start_activity( + chat_id=chat_jid, + message_id=message_id, + sender_jid=reaction_sender_jid, + ) + await self._handle_message( sender_id=sender_id, chat_id=chat_jid, diff --git a/tests/channels/test_whatsapp_channel.py b/tests/channels/test_whatsapp_channel.py index e3eee242a..ed68a346d 100644 --- a/tests/channels/test_whatsapp_channel.py +++ b/tests/channels/test_whatsapp_channel.py @@ -1,14 +1,20 @@ from __future__ import annotations import asyncio +from contextlib import suppress from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, call 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 +from nanobot.channels.whatsapp import ( + WhatsAppChannel, + _legacy_bridge_config_fields, + _NeonizeAPI, + _ReactionTarget, +) class _Proto: @@ -72,6 +78,11 @@ def _make_channel(config: dict | None = None) -> WhatsAppChannel: def _patch_neonize_api(monkeypatch) -> None: + chat_presence = SimpleNamespace( + CHAT_PRESENCE_COMPOSING="composing", + CHAT_PRESENCE_PAUSED="paused", + ) + chat_presence_media = SimpleNamespace(CHAT_PRESENCE_MEDIA_TEXT="text") monkeypatch.setattr( whatsapp_module, "_NEONIZE_API", @@ -82,6 +93,8 @@ def _patch_neonize_api(monkeypatch) -> None: MessageEv=object(), PairStatusEv=object(), build_jid=lambda user, server="s.whatsapp.net": (user, server), + ChatPresence=chat_presence, + ChatPresenceMedia=chat_presence_media, ), ) @@ -170,6 +183,195 @@ async def test_send_text_uses_neonize_send_message(monkeypatch) -> None: client.send_message.assert_awaited_once_with(("12345", "s.whatsapp.net"), "hi") +@pytest.mark.asyncio +async def test_send_text_passes_metadata_mentions_to_neonize(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", + metadata={ + "mentions": [ + "+15551234567@s.whatsapp.net", + {"jid": "15557654321@s.whatsapp.net"}, + "not-a-number", + ] + }, + ) + ) + + client.send_message.assert_awaited_once_with( + ("12345", "s.whatsapp.net"), + "hi", + ghost_mentions="@15551234567 @15557654321", + mentions_are_lids=False, + ) + + +@pytest.mark.asyncio +async def test_send_text_passes_lid_mentions_to_neonize(monkeypatch) -> None: + _patch_neonize_api(monkeypatch) + client = SimpleNamespace(send_message=AsyncMock()) + ch = _make_channel() + ch._client = client + ch._connected = True + + await ch.send( + OutboundMessage( + channel="whatsapp", + chat_id="12345@s.whatsapp.net", + content="hi", + metadata={"mentioned_jids": ["123456789012345@lid"]}, + ) + ) + + client.send_message.assert_awaited_once_with( + ("12345", "s.whatsapp.net"), + "hi", + ghost_mentions="@123456789012345", + mentions_are_lids=True, + ) + + +@pytest.mark.asyncio +async def test_inbound_message_starts_typing_and_reaction(monkeypatch) -> None: + _patch_neonize_api(monkeypatch) + client = SimpleNamespace( + download_any=AsyncMock(), + send_chat_presence=AsyncMock(), + build_reaction=AsyncMock(return_value="reaction-message"), + send_message=AsyncMock(), + ) + ch = _make_channel({"reactEmoji": "👀"}) + ch._client = client + ch._connected = True + ch._handle_message = AsyncMock() + + await ch._handle_neonize_message( + client, + _event( + message=_Proto(conversation="hello"), + message_id="wamid.1", + chat=_jid("120363000", "g.us"), + sender=_jid("LID99", "lid"), + sender_alt=_jid("15559998888", "s.whatsapp.net"), + is_group=True, + ), + ) + await asyncio.sleep(0) + + client.send_chat_presence.assert_any_await( + ("120363000", "g.us"), + "composing", + "text", + ) + client.build_reaction.assert_awaited_once_with( + ("120363000", "g.us"), + ("15559998888", "s.whatsapp.net"), + "wamid.1", + "👀", + ) + assert call(("120363000", "g.us"), "reaction-message") in client.send_message.await_args_list + assert ch._reaction_targets["120363000@g.us"] == _ReactionTarget( + "wamid.1", + "15559998888@s.whatsapp.net", + ) + + ch._stop_typing("120363000@g.us") + + +@pytest.mark.asyncio +async def test_final_send_stops_typing_and_removes_reaction(monkeypatch) -> None: + _patch_neonize_api(monkeypatch) + client = SimpleNamespace( + send_message=AsyncMock(), + send_chat_presence=AsyncMock(), + build_reaction=AsyncMock(return_value="remove-reaction"), + ) + ch = _make_channel() + ch._client = client + ch._connected = True + chat_id = "12345@s.whatsapp.net" + typing_task = asyncio.create_task(asyncio.sleep(60)) + ch._typing_tasks[chat_id] = typing_task + ch._reaction_targets[chat_id] = _ReactionTarget("wamid.1", "15551234567@s.whatsapp.net") + + await ch.send(OutboundMessage(channel="whatsapp", chat_id=chat_id, content="done")) + await asyncio.sleep(0) + + assert typing_task.cancelled() + assert chat_id not in ch._typing_tasks + assert chat_id not in ch._reaction_targets + client.send_chat_presence.assert_awaited_once_with( + ("12345", "s.whatsapp.net"), + "paused", + "text", + ) + client.build_reaction.assert_awaited_once_with( + ("12345", "s.whatsapp.net"), + ("15551234567", "s.whatsapp.net"), + "wamid.1", + "", + ) + client.send_message.assert_has_awaits( + [ + call(("12345", "s.whatsapp.net"), "remove-reaction"), + call(("12345", "s.whatsapp.net"), "done"), + ] + ) + + +@pytest.mark.asyncio +async def test_progress_send_keeps_typing_and_reaction(monkeypatch) -> None: + _patch_neonize_api(monkeypatch) + client = SimpleNamespace( + send_message=AsyncMock(), + send_chat_presence=AsyncMock(), + build_reaction=AsyncMock(return_value="remove-reaction"), + ) + ch = _make_channel() + ch._client = client + ch._connected = True + chat_id = "12345@s.whatsapp.net" + typing_task = asyncio.create_task(asyncio.sleep(60)) + ch._typing_tasks[chat_id] = typing_task + ch._reaction_targets[chat_id] = _ReactionTarget("wamid.1", "15551234567@s.whatsapp.net") + + await ch.send( + OutboundMessage( + channel="whatsapp", + chat_id=chat_id, + content="working", + metadata={"_progress": True}, + ) + ) + + assert ch._typing_tasks[chat_id] is typing_task + assert ch._reaction_targets[chat_id] == _ReactionTarget( + "wamid.1", + "15551234567@s.whatsapp.net", + ) + client.send_chat_presence.assert_not_awaited() + client.build_reaction.assert_not_awaited() + client.send_message.assert_awaited_once_with(("12345", "s.whatsapp.net"), "working") + + typing_task.cancel() + with suppress(asyncio.CancelledError): + await typing_task + + @pytest.mark.asyncio async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None: _patch_neonize_api(monkeypatch)