feat(whatsapp): add neonize activity cues and mentions

This commit is contained in:
Xubin Ren 2026-06-27 11:46:25 +08:00
parent 2bf111f456
commit aead911004
3 changed files with 399 additions and 3 deletions

View File

@ -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** **Migrating from the old bridge**
- Remove `bridgeUrl` and `bridgeToken`; WhatsApp no longer runs a local Node.js bridge. - Remove `bridgeUrl` and `bridgeToken`; WhatsApp no longer runs a local Node.js bridge.

View File

@ -29,6 +29,8 @@ class WhatsAppConfig(Base):
group_policy: Literal["open", "mention"] = "open" group_policy: Literal["open", "mention"] = "open"
database_path: str = "" database_path: str = ""
lid_mappings: dict[str, str] = Field(default_factory=dict) lid_mappings: dict[str, str] = Field(default_factory=dict)
typing_presence: bool = True
react_emoji: str = "👀"
class _NeonizeAPI(NamedTuple): class _NeonizeAPI(NamedTuple):
@ -38,6 +40,8 @@ class _NeonizeAPI(NamedTuple):
MessageEv: Any MessageEv: Any
PairStatusEv: Any PairStatusEv: Any
build_jid: Any build_jid: Any
ChatPresence: Any
ChatPresenceMedia: Any
class _MediaInfo(NamedTuple): class _MediaInfo(NamedTuple):
@ -48,6 +52,11 @@ class _MediaInfo(NamedTuple):
is_voice: bool = False is_voice: bool = False
class _ReactionTarget(NamedTuple):
message_id: str
sender_jid: str
_NEONIZE_API: _NeonizeAPI | None = None _NEONIZE_API: _NeonizeAPI | None = None
_JID_RE = re.compile(r"^(?P<user>[^@]+)@(?P<server>[^@]+)$") _JID_RE = re.compile(r"^(?P<user>[^@]+)@(?P<server>[^@]+)$")
_LEGACY_BRIDGE_CONFIG_FIELDS = ("bridgeUrl", "bridgeToken", "bridge_url", "bridge_token") _LEGACY_BRIDGE_CONFIG_FIELDS = ("bridgeUrl", "bridgeToken", "bridge_url", "bridge_token")
@ -69,6 +78,7 @@ def _load_neonize() -> _NeonizeAPI:
try: try:
from neonize.aioze.client import NewAClient from neonize.aioze.client import NewAClient
from neonize.aioze.events import ConnectedEv, DisconnectedEv, MessageEv, PairStatusEv from neonize.aioze.events import ConnectedEv, DisconnectedEv, MessageEv, PairStatusEv
from neonize.utils.enum import ChatPresence, ChatPresenceMedia
from neonize.utils.jid import build_jid from neonize.utils.jid import build_jid
except ImportError as exc: except ImportError as exc:
raise RuntimeError( raise RuntimeError(
@ -82,6 +92,8 @@ def _load_neonize() -> _NeonizeAPI:
MessageEv=MessageEv, MessageEv=MessageEv,
PairStatusEv=PairStatusEv, PairStatusEv=PairStatusEv,
build_jid=build_jid, build_jid=build_jid,
ChatPresence=ChatPresence,
ChatPresenceMedia=ChatPresenceMedia,
) )
return _NEONIZE_API return _NEONIZE_API
@ -176,6 +188,61 @@ def _classify_sender_ids(jids: list[Any]) -> tuple[str, str]:
return phone_id, lid_id 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]: def _context_infos(message: Any) -> list[Any]:
infos: list[Any] = [] infos: list[Any] = []
for container in ( for container in (
@ -293,6 +360,8 @@ class WhatsAppChannel(BaseChannel):
self._lid_to_phone = self._load_lid_mappings() self._lid_to_phone = self._load_lid_mappings()
self._self_jids: set[str] = set() self._self_jids: set[str] = set()
self._started_at = 0.0 self._started_at = 0.0
self._typing_tasks: dict[str, asyncio.Task[None]] = {}
self._reaction_targets: dict[str, _ReactionTarget] = {}
def _database_path(self) -> Path: def _database_path(self) -> Path:
configured = self.config.database_path.strip() configured = self.config.database_path.strip()
@ -359,6 +428,8 @@ class WhatsAppChannel(BaseChannel):
async def stop(self) -> None: async def stop(self) -> None:
self._running = False self._running = False
self._connected = False self._connected = False
for chat_id in list(self._typing_tasks):
self._stop_typing(chat_id)
client = self._client client = self._client
self._client = None self._client = None
if client is not None: if client is not None:
@ -394,8 +465,20 @@ class WhatsAppChannel(BaseChannel):
raise RuntimeError("WhatsApp channel is not connected") raise RuntimeError("WhatsApp channel is not connected")
to = self._build_jid(msg.chat_id) to = self._build_jid(msg.chat_id)
if not msg.metadata.get("_progress", False):
await self._finish_activity(msg.chat_id)
if msg.content: 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 []: for media_path in msg.media or []:
await self._send_media(client, to, media_path) await self._send_media(client, to, media_path)
@ -429,6 +512,91 @@ class WhatsAppChannel(BaseChannel):
mimetype=mimetype, 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( def _register_handlers(
self, self,
client: Any, client: Any,
@ -537,6 +705,7 @@ class WhatsAppChannel(BaseChannel):
sender_candidates = [sender_alt_jid, participant_jid] sender_candidates = [sender_alt_jid, participant_jid]
if not is_group: if not is_group:
sender_candidates.append(chat_jid) 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) phone_id, lid_id = _classify_sender_ids(sender_candidates)
if phone_id and lid_id: if phone_id and lid_id:
@ -552,6 +721,7 @@ class WhatsAppChannel(BaseChannel):
"is_forwarded": self._is_forwarded(message), "is_forwarded": self._is_forwarded(message),
"participant": participant_jid or None, "participant": participant_jid or None,
"sender_alt": sender_alt_jid or None, "sender_alt": sender_alt_jid or None,
"reaction_sender": reaction_sender_jid or None,
"lid": lid_id or None, "lid": lid_id or None,
"phone": phone_id or None, "phone": phone_id or None,
"is_reply_to_bot": self._is_reply_to_bot(message), "is_reply_to_bot": self._is_reply_to_bot(message),
@ -594,6 +764,12 @@ class WhatsAppChannel(BaseChannel):
if not text and not media_paths: if not text and not media_paths:
return return
await self._start_activity(
chat_id=chat_jid,
message_id=message_id,
sender_jid=reaction_sender_jid,
)
await self._handle_message( await self._handle_message(
sender_id=sender_id, sender_id=sender_id,
chat_id=chat_jid, chat_id=chat_jid,

View File

@ -1,14 +1,20 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from contextlib import suppress
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock, call
import pytest import pytest
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.channels import whatsapp as whatsapp_module 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: class _Proto:
@ -72,6 +78,11 @@ def _make_channel(config: dict | None = None) -> WhatsAppChannel:
def _patch_neonize_api(monkeypatch) -> None: 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( monkeypatch.setattr(
whatsapp_module, whatsapp_module,
"_NEONIZE_API", "_NEONIZE_API",
@ -82,6 +93,8 @@ def _patch_neonize_api(monkeypatch) -> None:
MessageEv=object(), MessageEv=object(),
PairStatusEv=object(), PairStatusEv=object(),
build_jid=lambda user, server="s.whatsapp.net": (user, server), 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") 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 @pytest.mark.asyncio
async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None: async def test_send_media_dispatches_by_mimetype(monkeypatch) -> None:
_patch_neonize_api(monkeypatch) _patch_neonize_api(monkeypatch)