From 6c2406c5e131dbbcabb69319c73c02594f63caea Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 16 May 2026 11:53:57 -0700 Subject: [PATCH] fix(signal): read groupV2.id in envelope, fall back to legacy groupInfo (#27051) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port from qwibitai/nanoclaw#1962: modern Signal V2-only groups surface on dataMessage.groupV2.id, not groupInfo.groupId. signal-cli versions differ in which field they expose for V2 groups — some forward the underlying libsignal envelope verbatim (groupV2), others normalize everything into groupInfo. Without a groupV2 read, V2-only groups appear as DMs because groupInfo is undefined and the adapter misroutes them to the sender's DM session. Reads groupV2.id first, falls back to groupInfo.groupId. Also hardens chat_name extraction against non-dict groupInfo payloads (crashed with AttributeError under malformed envelopes). 6 new tests cover V2 routing, V1 legacy compatibility, V2-preferred precedence, no-group DM path, allowlist enforcement, and malformed payloads. --- gateway/platforms/signal.py | 16 +++- tests/gateway/test_signal.py | 159 +++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+), 3 deletions(-) diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index bd731a7ab5d..2a0aa3f80c1 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -490,9 +490,19 @@ class SignalAdapter(BasePlatformAdapter): if not data_message: return - # Check for group message + # Check for group message. + # Modern Signal groups surface on dataMessage.groupV2.id; legacy V1 + # groups still arrive under dataMessage.groupInfo.groupId. signal-cli + # versions differ in which field they expose for V2 groups — some + # forward the underlying libsignal envelope verbatim (groupV2), others + # normalize everything into groupInfo. Read groupV2 first and fall + # back to groupInfo so V2-only groups aren't misrouted as DMs. group_info = data_message.get("groupInfo") - group_id = group_info.get("groupId") if group_info else None + group_v2 = data_message.get("groupV2") + group_id = ( + (group_v2.get("id") if isinstance(group_v2, dict) else None) + or (group_info.get("groupId") if isinstance(group_info, dict) else None) + ) is_group = bool(group_id) # Group message filtering — derived from SIGNAL_GROUP_ALLOWED_USERS: @@ -562,7 +572,7 @@ class SignalAdapter(BasePlatformAdapter): # Build session source source = self.build_source( chat_id=chat_id, - chat_name=group_info.get("groupName") if group_info else sender_name, + chat_name=(group_info.get("groupName") if isinstance(group_info, dict) else None) or sender_name, chat_type=chat_type, user_id=sender, user_name=sender_name or sender, diff --git a/tests/gateway/test_signal.py b/tests/gateway/test_signal.py index af81f59e8cd..7f34698f027 100644 --- a/tests/gateway/test_signal.py +++ b/tests/gateway/test_signal.py @@ -1794,3 +1794,162 @@ class TestSignalContentlessEnvelope: assert "event" in captured, "Normal message should NOT be skipped" assert captured["event"].text == "hello world" + + +# --------------------------------------------------------------------------- +# Envelope handling — group routing (legacy groupInfo vs modern groupV2) +# --------------------------------------------------------------------------- + +class TestSignalGroupV2Routing: + """Regression coverage for groupV2 envelope handling. + + signal-cli's JSON-RPC ``subscribeReceive`` envelope shape has drifted across + versions: some forward the underlying libsignal V2 envelope as + ``dataMessage.groupV2.id`` while older / normalized paths still use + ``dataMessage.groupInfo.groupId``. The adapter must read groupV2 first and + fall back to groupInfo so V2-only groups aren't misrouted as DMs. + + Ported from qwibitai/nanoclaw#1962 (V2 adapter improvements). + """ + + def _base_envelope(self, data_message: dict) -> dict: + return { + "envelope": { + "sourceNumber": "+15559998888", + "sourceUuid": "uuid-sender", + "sourceName": "Alice", + "timestamp": 1700000000000, + "dataMessage": data_message, + } + } + + @pytest.mark.asyncio + async def test_group_v2_id_routes_as_group(self, monkeypatch): + adapter = _make_signal_adapter(monkeypatch, group_allowed="*") + captured = [] + + async def _capture(event): + captured.append(event) + + adapter.handle_message = _capture + + env = self._base_envelope({ + "message": "hello v2", + "groupV2": {"id": "v2group=="}, + }) + + await adapter._handle_envelope(env) + + assert len(captured) == 1 + assert captured[0].source.chat_id == "group:v2group==" + assert captured[0].source.chat_type == "group" + assert captured[0].text == "hello v2" + + @pytest.mark.asyncio + async def test_legacy_group_info_still_works(self, monkeypatch): + adapter = _make_signal_adapter(monkeypatch, group_allowed="*") + captured = [] + + async def _capture(event): + captured.append(event) + + adapter.handle_message = _capture + + env = self._base_envelope({ + "message": "hello v1", + "groupInfo": {"groupId": "legacy=="}, + }) + + await adapter._handle_envelope(env) + + assert len(captured) == 1 + assert captured[0].source.chat_id == "group:legacy==" + assert captured[0].source.chat_type == "group" + + @pytest.mark.asyncio + async def test_group_v2_preferred_over_group_info(self, monkeypatch): + """When both fields are present, groupV2 wins — it's the authoritative V2 id.""" + adapter = _make_signal_adapter(monkeypatch, group_allowed="*") + captured = [] + + async def _capture(event): + captured.append(event) + + adapter.handle_message = _capture + + env = self._base_envelope({ + "message": "hello", + "groupV2": {"id": "v2=="}, + "groupInfo": {"groupId": "v1=="}, + }) + + await adapter._handle_envelope(env) + + assert len(captured) == 1 + assert captured[0].source.chat_id == "group:v2==" + + @pytest.mark.asyncio + async def test_no_group_fields_routes_as_dm(self, monkeypatch): + adapter = _make_signal_adapter(monkeypatch) + captured = [] + + async def _capture(event): + captured.append(event) + + adapter.handle_message = _capture + + env = self._base_envelope({"message": "direct message"}) + + await adapter._handle_envelope(env) + + assert len(captured) == 1 + assert captured[0].source.chat_type == "dm" + assert captured[0].source.chat_id == "+15559998888" + + @pytest.mark.asyncio + async def test_group_v2_respects_allowlist(self, monkeypatch): + """V2 group ids flow through the same SIGNAL_GROUP_ALLOWED_USERS filter.""" + adapter = _make_signal_adapter(monkeypatch, group_allowed="allowed-v2==") + captured = [] + + async def _capture(event): + captured.append(event) + + adapter.handle_message = _capture + + # Blocked group (not in allowlist) + await adapter._handle_envelope(self._base_envelope({ + "message": "blocked", + "groupV2": {"id": "blocked-v2=="}, + })) + assert len(captured) == 0 + + # Allowed group + await adapter._handle_envelope(self._base_envelope({ + "message": "allowed", + "groupV2": {"id": "allowed-v2=="}, + })) + assert len(captured) == 1 + assert captured[0].source.chat_id == "group:allowed-v2==" + + @pytest.mark.asyncio + async def test_malformed_group_fields_fall_through_to_dm(self, monkeypatch): + """Non-dict groupV2 / groupInfo shouldn't crash — treat as DM.""" + adapter = _make_signal_adapter(monkeypatch) + captured = [] + + async def _capture(event): + captured.append(event) + + adapter.handle_message = _capture + + env = self._base_envelope({ + "message": "malformed", + "groupV2": "not-a-dict", + "groupInfo": 42, + }) + + await adapter._handle_envelope(env) + + assert len(captured) == 1 + assert captured[0].source.chat_type == "dm"