mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
feat(whatsapp): add neonize activity cues and mentions
This commit is contained in:
parent
2bf111f456
commit
aead911004
@ -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.
|
||||
|
||||
@ -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<user>[^@]+)@(?P<server>[^@]+)$")
|
||||
_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,
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user