diff --git a/gateway/platforms/bluebubbles.py b/gateway/platforms/bluebubbles.py index 31595b223b54..d28f9f4baef3 100644 --- a/gateway/platforms/bluebubbles.py +++ b/gateway/platforms/bluebubbles.py @@ -31,7 +31,7 @@ cache_audio_from_bytes, cache_document_from_bytes, ) -from gateway.platforms.helpers import strip_markdown +from gateway.platforms.helpers import MessageDeduplicator, strip_markdown logger = logging.getLogger(__name__) @@ -151,6 +151,7 @@ def __init__(self, config: PlatformConfig): self._private_api_enabled: Optional[bool] = None self._helper_connected: bool = False self._guid_cache: OrderedDict[str, str] = OrderedDict() + self._dedup = MessageDeduplicator() # ------------------------------------------------------------------ # API helpers @@ -861,6 +862,42 @@ def _value(*candidates: Any) -> Optional[str]: return candidate.strip() return None + def _webhook_dedup_key( + self, + payload: Dict[str, Any], + record: Dict[str, Any], + message_id: Optional[str], + text: str, + ) -> Optional[str]: + """Build a replay key without dropping same-GUID lifecycle updates.""" + if not message_id: + return None + + attachments = [] + for attachment in record.get("attachments") or []: + if not isinstance(attachment, dict): + continue + attachments.append( + { + "guid": attachment.get("guid"), + "mimeType": attachment.get("mimeType"), + "transferName": attachment.get("transferName"), + "uti": attachment.get("uti"), + } + ) + + fingerprint = { + "event": self._value(payload.get("type"), payload.get("event")), + "text": text, + "associatedMessageGuid": record.get("associatedMessageGuid"), + "associatedMessageType": record.get("associatedMessageType"), + "threadOriginatorGuid": record.get("threadOriginatorGuid"), + "itemType": record.get("itemType"), + "attachments": attachments, + } + encoded = json.dumps(fingerprint, sort_keys=True, separators=(",", ":")) + return f"{message_id}:{encoded}" + async def _handle_webhook(self, request): from aiohttp import web @@ -921,6 +958,18 @@ async def _handle_webhook(self, request): ) or "" ) + message_id = self._value( + record.get("guid"), + record.get("messageGuid"), + record.get("id"), + ) + dedup_key = self._webhook_dedup_key(payload, record, message_id, text) + if dedup_key and self._dedup.is_duplicate(dedup_key): + logger.debug( + "[bluebubbles] duplicate webhook message ignored: %s", + _redact(message_id), + ) + return web.Response(text="ok") # --- Inbound attachment handling --- attachments = record.get("attachments") or [] @@ -1016,11 +1065,7 @@ async def _handle_webhook(self, request): message_type=msg_type, source=source, raw_message=payload, - message_id=self._value( - record.get("guid"), - record.get("messageGuid"), - record.get("id"), - ), + message_id=message_id, reply_to_message_id=self._value( record.get("threadOriginatorGuid"), record.get("associatedMessageGuid"), diff --git a/tests/gateway/test_bluebubbles.py b/tests/gateway/test_bluebubbles.py index 7d4a71378c0b..d2012f9b2ebf 100644 --- a/tests/gateway/test_bluebubbles.py +++ b/tests/gateway/test_bluebubbles.py @@ -378,6 +378,218 @@ def test_webhook_extracts_chat_guid_from_chats_array_group(self, monkeypatch): chat_guid = _chats[0].get("guid") or _chats[0].get("chatGuid") assert chat_guid == "any;+;chat-uuid-abc123" + @pytest.mark.asyncio + async def test_webhook_deduplicates_replayed_message_guid(self, monkeypatch): + adapter = _make_adapter(monkeypatch) + adapter.send_read_receipts = False + dispatched = [] + + async def fake_handle_message(event): + dispatched.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle_message) + payload = { + "type": "new-message", + "data": { + "guid": "MSG-GUID-1", + "text": "hello", + "handle": {"address": "user@example.com"}, + "isFromMe": False, + "chats": [ + { + "guid": "iMessage;-;user@example.com", + "chatIdentifier": "user@example.com", + } + ], + }, + } + + first = await adapter._handle_webhook(_FakeBlueBubblesRequest(payload)) + second = await adapter._handle_webhook(_FakeBlueBubblesRequest(payload)) + await asyncio.sleep(0) + + assert first.status == 200 + assert second.status == 200 + assert [event.message_id for event in dispatched] == ["MSG-GUID-1"] + + @pytest.mark.asyncio + async def test_webhook_deduplicates_before_replayed_attachment_download( + self, monkeypatch + ): + adapter = _make_adapter(monkeypatch) + adapter.send_read_receipts = False + dispatched = [] + downloads = [] + + async def fake_handle_message(event): + dispatched.append(event) + + async def fake_download_attachment(att_guid, att_meta): + downloads.append((att_guid, att_meta)) + return f"/tmp/{att_guid}.jpg" + + monkeypatch.setattr(adapter, "handle_message", fake_handle_message) + monkeypatch.setattr(adapter, "_download_attachment", fake_download_attachment) + payload = { + "type": "new-message", + "data": { + "guid": "MSG-GUID-1", + "text": "photo", + "handle": {"address": "user@example.com"}, + "isFromMe": False, + "attachments": [ + { + "guid": "ATT-GUID-1", + "mimeType": "image/jpeg", + "transferName": "photo.jpg", + } + ], + "chats": [ + { + "guid": "iMessage;-;user@example.com", + "chatIdentifier": "user@example.com", + } + ], + }, + } + + first = await adapter._handle_webhook(_FakeBlueBubblesRequest(payload)) + second = await adapter._handle_webhook(_FakeBlueBubblesRequest(payload)) + await asyncio.sleep(0) + + assert first.status == 200 + assert second.status == 200 + assert [guid for guid, _meta in downloads] == ["ATT-GUID-1"] + assert [event.message_id for event in dispatched] == ["MSG-GUID-1"] + + @pytest.mark.asyncio + async def test_webhook_allows_distinct_message_guids(self, monkeypatch): + adapter = _make_adapter(monkeypatch) + adapter.send_read_receipts = False + dispatched = [] + + async def fake_handle_message(event): + dispatched.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle_message) + + def payload(guid, text): + return { + "type": "new-message", + "data": { + "guid": guid, + "text": text, + "handle": {"address": "user@example.com"}, + "isFromMe": False, + "chats": [ + { + "guid": "iMessage;-;user@example.com", + "chatIdentifier": "user@example.com", + } + ], + }, + } + + await adapter._handle_webhook( + _FakeBlueBubblesRequest(payload("MSG-GUID-1", "hello")) + ) + await adapter._handle_webhook( + _FakeBlueBubblesRequest(payload("MSG-GUID-2", "again")) + ) + await asyncio.sleep(0) + + assert [event.message_id for event in dispatched] == [ + "MSG-GUID-1", + "MSG-GUID-2", + ] + + @pytest.mark.asyncio + async def test_webhook_allows_same_guid_text_updates(self, monkeypatch): + adapter = _make_adapter(monkeypatch) + adapter.send_read_receipts = False + dispatched = [] + + async def fake_handle_message(event): + dispatched.append(event) + + monkeypatch.setattr(adapter, "handle_message", fake_handle_message) + + def payload(text): + return { + "type": "updated-message", + "data": { + "guid": "MSG-GUID-1", + "text": text, + "handle": {"address": "user@example.com"}, + "isFromMe": False, + "chats": [ + { + "guid": "iMessage;-;user@example.com", + "chatIdentifier": "user@example.com", + } + ], + }, + } + + await adapter._handle_webhook(_FakeBlueBubblesRequest(payload("hello"))) + await adapter._handle_webhook( + _FakeBlueBubblesRequest(payload("hello, edited")) + ) + await asyncio.sleep(0) + + assert [event.text for event in dispatched] == ["hello", "hello, edited"] + + @pytest.mark.asyncio + async def test_webhook_allows_same_guid_attachment_completion( + self, monkeypatch + ): + adapter = _make_adapter(monkeypatch) + adapter.send_read_receipts = False + dispatched = [] + + async def fake_handle_message(event): + dispatched.append(event) + + async def fake_download_attachment(att_guid, att_meta): + return f"/tmp/{att_guid}.jpg" + + monkeypatch.setattr(adapter, "handle_message", fake_handle_message) + monkeypatch.setattr(adapter, "_download_attachment", fake_download_attachment) + + base = { + "type": "updated-message", + "data": { + "guid": "MSG-GUID-1", + "text": "photo", + "handle": {"address": "user@example.com"}, + "isFromMe": False, + "chats": [ + { + "guid": "iMessage;-;user@example.com", + "chatIdentifier": "user@example.com", + } + ], + }, + } + completed = json.loads(json.dumps(base)) + completed["data"]["attachments"] = [ + { + "guid": "ATT-GUID-1", + "mimeType": "image/jpeg", + "transferName": "photo.jpg", + } + ] + + await adapter._handle_webhook(_FakeBlueBubblesRequest(base)) + await adapter._handle_webhook(_FakeBlueBubblesRequest(completed)) + await asyncio.sleep(0) + + assert [event.message_id for event in dispatched] == [ + "MSG-GUID-1", + "MSG-GUID-1", + ] + assert dispatched[1].media_urls == ["/tmp/ATT-GUID-1.jpg"] + def test_extract_payload_record_accepts_list_data(self, monkeypatch): adapter = _make_adapter(monkeypatch) payload = {