Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 51 additions & 6 deletions gateway/platforms/bluebubbles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 []
Expand Down Expand Up @@ -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"),
Expand Down
212 changes: 212 additions & 0 deletions tests/gateway/test_bluebubbles.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading