From a6b68178aa88a5944b01bc05cc2d580dc8c7c000 Mon Sep 17 00:00:00 2001 From: chengyongru <61816729+chengyongru@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:16:37 +0800 Subject: [PATCH] fix(whatsapp): allow group ids in allowFrom (#4834) --- docs/chat-apps.md | 4 +++ nanobot/channels/base.py | 12 +++++-- nanobot/channels/whatsapp.py | 14 +++++++- tests/channels/test_base_channel.py | 32 ++++++++++++++++++ tests/channels/test_whatsapp_channel.py | 45 +++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 3 deletions(-) diff --git a/docs/chat-apps.md b/docs/chat-apps.md index cb1e69c9..9ead67a0 100644 --- a/docs/chat-apps.md +++ b/docs/chat-apps.md @@ -393,6 +393,10 @@ nanobot channels login whatsapp } ``` +For groups, `allowFrom` can contain either a participant sender ID/LID or a +group JID/bare group ID. A participant entry allows that sender wherever the bot +can see them; a group entry allows replies in that group. + Optional session database path: ```json diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index ce0fe570..04c71829 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -224,9 +224,17 @@ class BaseChannel(ABC): metadata: dict[str, Any] | None = None, session_key: str | None = None, is_dm: bool = False, + authorization_id: str | None = None, ) -> None: - """Handle an incoming message: check permissions, issue pairing codes in DMs, or forward to bus.""" - if not self.is_allowed(sender_id): + """Handle a message after checking its authorization subject. + + ``sender_id`` is the identity recorded on the inbound message. Channels + where access is scoped to another entity (for example, a group or room) + can pass that entity as ``authorization_id`` without changing the + sender's identity. When omitted, authorization remains sender-based. + """ + permission_id = authorization_id if authorization_id is not None else sender_id + if not self.is_allowed(permission_id): if is_dm: code = generate_code(self.name, str(sender_id)) await self.send( diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index d69bcbd2..b70c05da 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -583,7 +583,10 @@ class WhatsAppChannel(BaseChannel): "phone": phone_id or None, "is_reply_to_bot": self._is_reply_to_bot(message), } - if not self.is_allowed(sender_id): + sender_allowed = self.is_allowed(sender_id) + group_allow_id = self._group_allow_id(chat_jid) if is_group else None + authorization_id = sender_id if sender_allowed else group_allow_id + if authorization_id is None: self.logger.info( "Passing unauthorized WhatsApp sender {} to pairing flow " "(phone={}, lid={}, chat={})", @@ -628,8 +631,17 @@ class WhatsAppChannel(BaseChannel): media=media_paths, metadata=metadata, is_dm=not is_group, + authorization_id=authorization_id, ) + def _group_allow_id(self, chat_jid: str) -> str | None: + if self.is_allowed(chat_jid): + return chat_jid + bare_chat_id = _bare_jid(chat_jid) + if bare_chat_id and bare_chat_id != chat_jid and self.is_allowed(bare_chat_id): + return bare_chat_id + return None + def _is_addressed_to_bot(self, message: Any) -> bool: return self._was_mentioned(message) or self._is_reply_to_bot(message) diff --git a/tests/channels/test_base_channel.py b/tests/channels/test_base_channel.py index dca1b8a7..177371a8 100644 --- a/tests/channels/test_base_channel.py +++ b/tests/channels/test_base_channel.py @@ -93,3 +93,35 @@ async def test_handle_message_group_ignores_unknown() -> None: assert channel._sent == [] + +@pytest.mark.asyncio +async def test_handle_message_uses_authorization_id_without_changing_sender() -> None: + bus = MessageBus() + channel = _DummyChannel({"allowFrom": ["group@g.us"]}, bus) + + await channel._handle_message( + sender_id="member-lid", + authorization_id="group@g.us", + chat_id="group@g.us", + content="hello", + ) + + msg = await bus.consume_inbound() + assert msg.sender_id == "member-lid" + assert msg.chat_id == "group@g.us" + + +@pytest.mark.asyncio +async def test_handle_message_rejects_when_authorization_id_is_not_allowed() -> None: + bus = MessageBus() + channel = _DummyChannel({"allowFrom": ["member-lid"]}, bus) + + await channel._handle_message( + sender_id="member-lid", + authorization_id="other-group@g.us", + chat_id="other-group@g.us", + content="hello", + ) + + assert bus.inbound_size == 0 + diff --git a/tests/channels/test_whatsapp_channel.py b/tests/channels/test_whatsapp_channel.py index 420a4a05..8da16732 100644 --- a/tests/channels/test_whatsapp_channel.py +++ b/tests/channels/test_whatsapp_channel.py @@ -9,6 +9,7 @@ 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 @@ -320,6 +321,50 @@ async def test_group_sender_id_uses_participant_not_group_jid() -> None: 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()