Skip to content
Closed
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
21 changes: 20 additions & 1 deletion gateway/platforms/bluebubbles.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,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 @@ -129,6 +129,14 @@ def __init__(self, config: PlatformConfig):
self._private_api_enabled: Optional[bool] = None
self._helper_connected: bool = False
self._guid_cache: Dict[str, str] = {}
# Suppress duplicate inbound webhooks for the same message GUID.
# BlueBubbles fires both `new-message` and `updated-message` on
# delivered/read/edit echoes for the same message; without dedup the
# second event normalizes to a different chat key (chatIdentifier vs
# chatGuid) and spins up a parallel session for the same chat.
# Explicit bounds: 2000 entries / 5 min TTL cap memory for
# long-running gateway processes.
self._dedup = MessageDeduplicator(max_size=2000, ttl_seconds=300)

# ------------------------------------------------------------------
# API helpers
Expand Down Expand Up @@ -824,6 +832,17 @@ async def _handle_webhook(self, request):
if is_from_me:
return web.Response(text="ok")

# Dedup by message GUID before any session-key derivation. Mirrors
# the pattern used by slack/dingtalk/wecom/weixin/mattermost/feishu
# via the same `MessageDeduplicator` helper.
msg_guid = self._value(
record.get("guid"),
record.get("messageGuid"),
record.get("id"),
)
if msg_guid and self._dedup.is_duplicate(msg_guid):
return web.Response(text="ok")

# Skip tapback reactions delivered as messages
assoc_type = record.get("associatedMessageType")
if isinstance(assoc_type, int) and assoc_type in {
Expand Down
109 changes: 109 additions & 0 deletions tests/gateway/test_bluebubbles.py
Original file line number Diff line number Diff line change
Expand Up @@ -698,3 +698,112 @@ async def bad_get(path):
adapter._unregister_webhook()
)
assert ok is False


class _FakeWebhookRequest:
"""Stand-in for aiohttp's ``web.Request`` in inbound-webhook tests."""

def __init__(self, payload, password):
self._payload = payload
self.query = {"password": password}
self.headers = {}

async def read(self):
import json
return json.dumps(self._payload).encode("utf-8")


class TestBlueBubblesInboundDedup:
"""Regression coverage for #30708 — exact-GUID dedup across new-message
and updated-message webhook events for the same iMessage."""

@pytest.mark.asyncio
async def test_same_guid_new_then_updated_message_dedups(self, monkeypatch):
import asyncio

adapter = _make_adapter(monkeypatch)
delivered = []
delivered_event = asyncio.Event()

async def fake_handle_message(event):
delivered.append(event)
delivered_event.set()

monkeypatch.setattr(adapter, "handle_message", fake_handle_message)

new_msg = {
"type": "new-message",
"data": {
"guid": "MESSAGE-GUID-1",
"text": "yes",
"handle": {"address": "+15551234567"},
"isFromMe": False,
"chatGuid": "any;-;+15551234567",
"chatIdentifier": "+15551234567",
},
}
# Same message GUID, updated-message without chatGuid. Pre-fix this
# was treated as a new message and normalized to a bare
# chatIdentifier session key, spinning up a parallel session.
upd_msg = {
"type": "updated-message",
"data": {
"guid": "MESSAGE-GUID-1",
"text": "yes",
"handle": {"address": "+15551234567"},
"isFromMe": False,
"chatIdentifier": "+15551234567",
},
}

r1 = await adapter._handle_webhook(_FakeWebhookRequest(new_msg, "secret"))
await asyncio.wait_for(delivered_event.wait(), timeout=2.0)
r2 = await adapter._handle_webhook(_FakeWebhookRequest(upd_msg, "secret"))
# Drain any background tasks from the second request so a stray
# delivery would have a turn to fire before we assert dedup.
for _ in range(5):
await asyncio.sleep(0)

assert r1.status == 200
assert r2.status == 200
assert len(delivered) == 1, (
"duplicate inbound webhook for the same message GUID was not "
"suppressed; second event would have created a parallel session"
)
assert delivered[0].text == "yes"

@pytest.mark.asyncio
async def test_different_guids_both_delivered(self, monkeypatch):
"""Two distinct iMessages must still both be delivered."""
import asyncio

adapter = _make_adapter(monkeypatch)
delivered = []
delivered_count = asyncio.Event()

async def fake_handle_message(event):
delivered.append(event)
if len(delivered) >= 2:
delivered_count.set()

monkeypatch.setattr(adapter, "handle_message", fake_handle_message)

def _msg(guid, text):
return {
"type": "new-message",
"data": {
"guid": guid,
"text": text,
"handle": {"address": "+15551234567"},
"isFromMe": False,
"chatGuid": "any;-;+15551234567",
},
}

r1 = await adapter._handle_webhook(_FakeWebhookRequest(_msg("M1", "hi"), "secret"))
r2 = await adapter._handle_webhook(_FakeWebhookRequest(_msg("M2", "there"), "secret"))
await asyncio.wait_for(delivered_count.wait(), timeout=2.0)

assert r1.status == 200
assert r2.status == 200
assert [e.text for e in delivered] == ["hi", "there"]
Loading