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
This commit is contained in:
Chris Danis 2026-04-30 22:13:42 -04:00 committed by Teknium
parent e2e6b6ff1a
commit f61695ee73
2 changed files with 157 additions and 0 deletions

View File

@ -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,

View File

@ -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"