Compare commits

...

1 Commits

Author SHA1 Message Date
Teknium
61b65eeb0c fix(signal): read groupV2.id in envelope, fall back to legacy groupInfo
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.
2026-04-26 17:04:57 -07:00
2 changed files with 172 additions and 3 deletions

View File

@@ -460,9 +460,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:
@@ -515,7 +525,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,

View File

@@ -997,3 +997,162 @@ class TestSignalTypingBackoff:
assert "+155****4567" not in adapter._typing_failures
assert "+155****4567" not in adapter._typing_skip_until
# ---------------------------------------------------------------------------
# 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"