From f61695ee73fce5427a213258799844a48a66bc9c Mon Sep 17 00:00:00 2001 From: Chris Danis Date: Thu, 30 Apr 2026 22:13:42 -0400 Subject: [PATCH] fix(signal): skip contentless envelopes (profile key updates, empty messages) Signal-cli sends dataMessage wrappers for profile key updates and other metadata events that have no actual text content. These were reaching the gateway as msg='' and triggering full agent turns for nothing. Add early return in _handle_envelope() when both message field is empty/ missing/whitespace AND there are no attachments. Messages with media attachments but no text still flow through. - 12 lines added to gateway/platforms/signal.py - 5 new tests in TestSignalContentlessEnvelope class --- gateway/platforms/signal.py | 12 +++ tests/gateway/test_signal.py | 145 +++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index 0ad1ef75..22543060 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -534,6 +534,18 @@ class SignalAdapter(BasePlatformAdapter): except Exception: logger.exception("Signal: failed to fetch attachment %s", att_id) + # Skip envelopes with no meaningful content (no text, no attachments). + # Catches profile key updates, empty messages, and other metadata-only + # envelopes that still carry a dataMessage wrapper but have nothing + # worth processing. See issue: signal-cli logs "Profile key update" + + # Hermes receives msg='' triggering a full agent turn for nothing. + if (not text or not text.strip()) and not media_urls: + logger.debug( + "Signal: skipping contentless envelope from %s (%d attachments)", + redact_phone(sender), len(media_urls) if media_urls else 0, + ) + return + # Build session source source = self.build_source( chat_id=chat_id, diff --git a/tests/gateway/test_signal.py b/tests/gateway/test_signal.py index 8aab559a..af81f59e 100644 --- a/tests/gateway/test_signal.py +++ b/tests/gateway/test_signal.py @@ -1649,3 +1649,148 @@ class TestSignalSendTimeout: # 32 attachments × 5s = 160s; ought to comfortably outlast a # serial upload of an attachment-heavy batch. assert _signal_send_timeout(32) == 160.0 + + +# --------------------------------------------------------------------------- +# Contentless Envelope Filtering (profile key updates, empty messages) +# --------------------------------------------------------------------------- + +class TestSignalContentlessEnvelope: + """Verify that profile key updates and empty Signal messages are skipped.""" + + @pytest.mark.asyncio + async def test_skips_profile_key_update_no_message_field(self, monkeypatch): + """Profile key updates may carry a dataMessage without 'message' field. + Must be skipped to avoid triggering agent turns for metadata.""" + adapter = _make_signal_adapter(monkeypatch) + captured = {} + + async def fake_handle(event): + captured["event"] = event + + adapter.handle_message = fake_handle + + # Profile key update: dataMessage exists but has no "message" field + await adapter._handle_envelope({ + "envelope": { + "sourceNumber": "+155****9999", + "sourceUuid": "05668cf3-8ffa-467e-9b24-f5eefa5cf475", + "sourceName": "Elliott McManis", + "timestamp": 1777600696077, + "dataMessage": { + # No "message" field — profile key update metadata only + "profileKey": "some-profile-key-data", + }, + } + }) + + assert "event" not in captured, "Profile key update should be skipped" + + @pytest.mark.asyncio + async def test_skips_empty_message(self, monkeypatch): + """Empty text messages (message='') should be skipped.""" + adapter = _make_signal_adapter(monkeypatch) + captured = {} + + async def fake_handle(event): + captured["event"] = event + + adapter.handle_message = fake_handle + + await adapter._handle_envelope({ + "envelope": { + "sourceNumber": "+155****9999", + "sourceUuid": "05668cf3-8ffa-467e-9b24-f5eefa5cf475", + "sourceName": "Elliott McManis", + "timestamp": 1777600696077, + "dataMessage": { + "message": "", + }, + } + }) + + assert "event" not in captured, "Empty message should be skipped" + + @pytest.mark.asyncio + async def test_skips_whitespace_only_message(self, monkeypatch): + """Whitespace-only messages (' ') should be skipped.""" + adapter = _make_signal_adapter(monkeypatch) + captured = {} + + async def fake_handle(event): + captured["event"] = event + + adapter.handle_message = fake_handle + + await adapter._handle_envelope({ + "envelope": { + "sourceNumber": "+155****9999", + "sourceUuid": "05668cf3-8ffa-467e-9b24-f5eefa5cf475", + "sourceName": "Elliott McManis", + "timestamp": 1777600696077, + "dataMessage": { + "message": " \n\t ", + }, + } + }) + + assert "event" not in captured, "Whitespace-only message should be skipped" + + @pytest.mark.asyncio + async def test_allows_message_with_attachment_no_text(self, monkeypatch): + """Messages with attachments but no text should still be processed.""" + adapter = _make_signal_adapter(monkeypatch) + captured = {} + + async def fake_handle(event): + captured["event"] = event + + adapter.handle_message = fake_handle + + # Mock attachment fetch to return a cached image + png_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 + b64_data = base64.b64encode(png_data).decode() + adapter._rpc, _ = _stub_rpc({"data": b64_data}) + + with patch("gateway.platforms.signal.cache_image_from_bytes", return_value="/tmp/img.png"): + await adapter._handle_envelope({ + "envelope": { + "sourceNumber": "+155****9999", + "sourceUuid": "05668cf3-8ffa-467e-9b24-f5eefa5cf475", + "sourceName": "Elliott McManis", + "timestamp": 1777600696077, + "dataMessage": { + "message": "", # No text + "attachments": [{"id": "att-123", "size": 200}], + }, + } + }) + + assert "event" in captured, "Message with attachment should NOT be skipped" + assert captured["event"].media_urls == ["/tmp/img.png"] + + @pytest.mark.asyncio + async def test_allows_normal_text_message(self, monkeypatch): + """Normal text messages should still flow through.""" + adapter = _make_signal_adapter(monkeypatch) + captured = {} + + async def fake_handle(event): + captured["event"] = event + + adapter.handle_message = fake_handle + + await adapter._handle_envelope({ + "envelope": { + "sourceNumber": "+155****9999", + "sourceUuid": "05668cf3-8ffa-467e-9b24-f5eefa5cf475", + "sourceName": "Elliott McManis", + "timestamp": 1777600696077, + "dataMessage": { + "message": "hello world", + }, + } + }) + + assert "event" in captured, "Normal message should NOT be skipped" + assert captured["event"].text == "hello world"