From 39e7569715b6f476ee8c2b2a60240d0eed898e27 Mon Sep 17 00:00:00 2001 From: SYMBaiEX Date: Thu, 6 Aug 2026 20:57:47 -0500 Subject: [PATCH 1/2] Fix current LINQ webhook compatibility --- README.md | 9 +++--- adapter.py | 20 +++++++++--- signing.py | 73 ++++++++++++++++++++++++++++++++++++------ tests/test_signing.py | 74 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index e409091..189e405 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,7 @@ Set `LINQ_ALLOW_ALL_USERS=true` only for local development. - Real iMessage blue bubbles via the Linq API — no Mac - Inbound via HMAC-SHA256 signed webhooks (replay-protected, 5-min drift window) +- Legacy (`2025-01-01`) and current (`2026-02-03`) `message.received` payloads - At-least-once delivery dedup on `message.id` - Outbound text + media-by-URL, typing indicators, read receipts - Inbound image attachments downloaded locally for the vision tools @@ -196,11 +197,9 @@ runtime. ## Notes / assumptions to confirm against a live account -- **Group detection.** Linq's documented `message.received` payload (mirrored - from the OpenClaw plugin) does not include a first-class chat-type field, so - `signing.is_group_chat()` infers group vs. DM from `is_group` / `group_id` / - `participants`. Confirm against a real Linq group webhook and tighten if Linq - exposes an explicit type. +- **Webhook versions.** Current `2026-02-03` payloads use `chat.is_group` + directly. Legacy `2025-01-01` payloads retain the adapter's fallback group + heuristics for compatibility. - **Outbound media.** Linq sends media by **public URL**, not multipart upload, so `send_image` forwards a URL and the standalone/cron path skips local files with a logged note. If your Linq plan offers an upload endpoint, wire it into diff --git a/adapter.py b/adapter.py index a341554..01b6dd8 100644 --- a/adapter.py +++ b/adapter.py @@ -195,7 +195,8 @@ def __init__(self, config: PlatformConfig): # -- Connection lifecycle --------------------------------------------- - async def connect(self) -> bool: + async def connect(self, *, is_reconnect: bool = False) -> bool: + """Start Linq; webhook delivery needs no reconnect-specific queue handling.""" if not AIOHTTP_AVAILABLE: self._set_fatal_error( "MISSING_DEP", "aiohttp not installed. Run: pip install aiohttp", retryable=False @@ -306,7 +307,11 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response": ) return web.Response(text="ok") - data = payload.get("data") or {} + raw_data = payload.get("data") + data = signing.normalize_message_received_data(raw_data) + if data is None: + logger.warning("[linq] rejected message.received with unsupported payload shape") + return web.Response(status=400, text="invalid message.received data") message = data.get("message") or {} msg_id = message.get("id") if not msg_id: @@ -315,7 +320,7 @@ async def _handle_webhook(self, request: "web.Request") -> "web.Response": return web.Response(text="ok (dup)") try: - await self._dispatch_inbound(data) + await self._dispatch_inbound(data, raw_message=raw_data) except Exception: logger.exception("[linq] inbound dispatch failed") # 200 anyway — we own the dedup; failing here would make Linq retry @@ -334,7 +339,12 @@ def _is_duplicate(self, msg_id: str) -> bool: self._seen_messages[msg_id] = now return False - async def _dispatch_inbound(self, data: Dict[str, Any]) -> None: + async def _dispatch_inbound( + self, + data: Dict[str, Any], + *, + raw_message: Any = None, + ) -> None: sender = (data.get("from") or "").strip() if not sender: logger.warning("[linq] inbound missing sender") @@ -414,7 +424,7 @@ async def _dispatch_inbound(self, data: Dict[str, Any]) -> None: message_type=mtype, source=source, message_id=message.get("id"), - raw_message=data, + raw_message=data if raw_message is None else raw_message, timestamp=timestamp, media_urls=media_urls, media_types=media_types, diff --git a/signing.py b/signing.py index d6afe19..d18e9c1 100644 --- a/signing.py +++ b/signing.py @@ -188,17 +188,72 @@ def extract_media(parts: list) -> "list[dict]": return out -def is_group_chat(data: dict) -> bool: - """Heuristically classify an inbound Linq message as group vs. direct. +def normalize_message_received_data(raw: object) -> "Optional[dict]": + """Normalize legacy and current Linq ``message.received`` payloads. + + Early Linq webhook payloads nested message fields under ``data.message``. + Current deliveries use the API message resource directly, with ``id`` and + ``parts`` on ``data`` plus nested ``chat`` and ``sender_handle`` records. + Return the legacy-shaped contract consumed by the Hermes adapter so both + payload generations follow the same dispatch path. + """ + if not isinstance(raw, dict): + return None + + legacy_message = raw.get("message") + if ( + isinstance(raw.get("chat_id"), str) + and isinstance(raw.get("from"), str) + and isinstance(legacy_message, dict) + and isinstance(legacy_message.get("id"), str) + and isinstance(legacy_message.get("parts"), list) + ): + return raw + + chat = raw.get("chat") + sender = raw.get("sender_handle") + if not isinstance(chat, dict) or not isinstance(sender, dict): + return None + if not isinstance(chat.get("id"), str): + return None + if not isinstance(sender.get("handle"), str): + return None + if not isinstance(raw.get("id"), str) or not isinstance(raw.get("parts"), list): + return None + + owner = chat.get("owner_handle") + recipient_phone = owner.get("handle") if isinstance(owner, dict) else None + if not isinstance(recipient_phone, str) or not recipient_phone: + recipient_phone = None + received_at = raw.get("sent_at") + if not isinstance(received_at, str): + received_at = "" + return { + "chat_id": chat["id"], + "from": sender["handle"], + "recipient_phone": recipient_phone, + "received_at": received_at, + "is_from_me": raw.get("direction") == "outbound", + "is_group": chat.get("is_group") is True, + "service": raw.get("service"), + "message": { + "id": raw["id"], + "parts": raw["parts"], + "reply_to": raw.get("reply_to"), + }, + } - Linq's Blue v3 ``message.received`` payload does not (in the documented - shape we mirror from the OpenClaw channel) carry an explicit chat-type - discriminator, so we look at the fields a group delivery is known to add: - an ``is_group`` flag, a ``group_id``/``group_name``, or a ``participants`` - list with more than two members. Everything else is treated as a DM. - NOTE: confirm against a live Linq group webhook and tighten if the real - payload exposes a first-class type field. +def is_group_chat(data: dict) -> bool: + """Classify an inbound Linq message as group vs. direct. + + Current Linq ``message.received`` payloads carry a first-class + ``chat.is_group`` flag, which ``normalize_message_received_data`` maps to + ``is_group`` on the normalized contract. Legacy payloads lack a chat-type + discriminator, so for those we fall back to the fields a group delivery is + known to add: an ``is_group`` flag, a ``group_id``/``group_name``, or a + ``participants`` list with more than two members. Everything else is + treated as a DM. """ if coerce_bool(data.get("is_group")): return True diff --git a/tests/test_signing.py b/tests/test_signing.py index 9599799..22f74fe 100644 --- a/tests/test_signing.py +++ b/tests/test_signing.py @@ -20,6 +20,24 @@ def _sign(secret: str, ts: int, body: bytes) -> str: return hmac.new(secret.encode("utf-8"), msg, hashlib.sha256).hexdigest() +def _current_message_data(**overrides): + data = { + "id": "msg-2", + "parts": [{"type": "text", "value": "hello"}], + "direction": "inbound", + "service": "iMessage", + "sent_at": "2026-08-04T08:12:19Z", + "sender_handle": {"handle": "+15550001111", "is_me": False}, + "chat": { + "id": "chat-2", + "is_group": False, + "owner_handle": {"handle": "+15550002222"}, + }, + } + data.update(overrides) + return data + + class VerifySignatureTest(unittest.TestCase): def setUp(self): self.secret = "s3cr3t" @@ -140,6 +158,62 @@ def test_invalid_regex_skipped(self): class ParsingTest(unittest.TestCase): + def test_normalize_legacy_message_received_data(self): + data = { + "chat_id": "chat-1", + "from": "+15550001111", + "message": {"id": "msg-1", "parts": [{"type": "text", "value": "hi"}]}, + } + self.assertIs(signing.normalize_message_received_data(data), data) + + def test_normalize_current_message_received_data(self): + data = _current_message_data( + reply_to={"message_id": "msg-1", "part_index": 0}, + ) + normalized = signing.normalize_message_received_data(data) + self.assertIsNotNone(normalized) + self.assertEqual(normalized["chat_id"], "chat-2") + self.assertEqual(normalized["from"], "+15550001111") + self.assertEqual(normalized["recipient_phone"], "+15550002222") + self.assertEqual(normalized["received_at"], "2026-08-04T08:12:19Z") + self.assertFalse(normalized["is_from_me"]) + self.assertEqual(normalized["service"], "iMessage") + self.assertEqual(normalized["message"]["id"], "msg-2") + self.assertEqual(normalized["message"]["parts"][0]["value"], "hello") + self.assertEqual(normalized["message"]["reply_to"]["message_id"], "msg-1") + + def test_normalize_current_payload_marks_outbound_echoes(self): + outbound = signing.normalize_message_received_data( + _current_message_data(direction="outbound") + ) + self.assertTrue(outbound["is_from_me"]) + + def test_normalize_current_payload_allows_missing_owner_handle(self): + data = _current_message_data( + chat={"id": "chat-2", "is_group": False}, + ) + normalized = signing.normalize_message_received_data(data) + self.assertIsNone(normalized["recipient_phone"]) + + def test_normalize_rejects_incomplete_current_payload(self): + self.assertIsNone(signing.normalize_message_received_data({"id": "msg-3"})) + + def test_normalize_current_payload_maps_group_flag(self): + dm = signing.normalize_message_received_data(_current_message_data()) + group = signing.normalize_message_received_data( + _current_message_data( + chat={ + "id": "chat-3", + "is_group": True, + "owner_handle": {"handle": "+15550002222"}, + }, + ) + ) + self.assertFalse(dm["is_group"]) + self.assertFalse(signing.is_group_chat(dm)) + self.assertTrue(group["is_group"]) + self.assertTrue(signing.is_group_chat(group)) + def test_extract_text_joins_text_parts(self): parts = [ {"type": "text", "value": "hello"}, From 2e1cd3c1979561544c2d6c6f8c11c89d9715d82f Mon Sep 17 00:00:00 2001 From: SYMBaiEX Date: Thu, 6 Aug 2026 21:22:29 -0500 Subject: [PATCH 2/2] Validate normalized webhook fields --- signing.py | 15 ++++++++++----- tests/test_signing.py | 16 ++++++++++++++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/signing.py b/signing.py index d18e9c1..38754a1 100644 --- a/signing.py +++ b/signing.py @@ -214,9 +214,11 @@ def normalize_message_received_data(raw: object) -> "Optional[dict]": sender = raw.get("sender_handle") if not isinstance(chat, dict) or not isinstance(sender, dict): return None - if not isinstance(chat.get("id"), str): + chat_id = chat.get("id") + sender_handle = sender.get("handle") + if not isinstance(chat_id, str) or not chat_id.strip(): return None - if not isinstance(sender.get("handle"), str): + if not isinstance(sender_handle, str) or not sender_handle.strip(): return None if not isinstance(raw.get("id"), str) or not isinstance(raw.get("parts"), list): return None @@ -228,9 +230,12 @@ def normalize_message_received_data(raw: object) -> "Optional[dict]": received_at = raw.get("sent_at") if not isinstance(received_at, str): received_at = "" + reply_to = raw.get("reply_to") + if not isinstance(reply_to, dict): + reply_to = None return { - "chat_id": chat["id"], - "from": sender["handle"], + "chat_id": chat_id, + "from": sender_handle, "recipient_phone": recipient_phone, "received_at": received_at, "is_from_me": raw.get("direction") == "outbound", @@ -239,7 +244,7 @@ def normalize_message_received_data(raw: object) -> "Optional[dict]": "message": { "id": raw["id"], "parts": raw["parts"], - "reply_to": raw.get("reply_to"), + "reply_to": reply_to, }, } diff --git a/tests/test_signing.py b/tests/test_signing.py index 22f74fe..60dafe6 100644 --- a/tests/test_signing.py +++ b/tests/test_signing.py @@ -195,8 +195,20 @@ def test_normalize_current_payload_allows_missing_owner_handle(self): normalized = signing.normalize_message_received_data(data) self.assertIsNone(normalized["recipient_phone"]) - def test_normalize_rejects_incomplete_current_payload(self): - self.assertIsNone(signing.normalize_message_received_data({"id": "msg-3"})) + def test_normalize_handles_invalid_current_payloads(self): + invalid_required_fields = ( + {"id": "msg-3"}, + _current_message_data(chat={"id": "", "is_group": False}), + _current_message_data(sender_handle={"handle": " "}), + ) + for data in invalid_required_fields: + with self.subTest(data=data): + self.assertIsNone(signing.normalize_message_received_data(data)) + + malformed_reply = signing.normalize_message_received_data( + _current_message_data(reply_to="msg-1") + ) + self.assertIsNone(malformed_reply["message"]["reply_to"]) def test_normalize_current_payload_maps_group_flag(self): dm = signing.normalize_message_received_data(_current_message_data())