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
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
20 changes: 15 additions & 5 deletions adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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")
Expand Down Expand Up @@ -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,
Expand Down
78 changes: 69 additions & 9 deletions signing.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,17 +188,77 @@ 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
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_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

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 = ""
reply_to = raw.get("reply_to")
if not isinstance(reply_to, dict):
reply_to = None
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": 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
Expand Down
86 changes: 86 additions & 0 deletions tests/test_signing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -140,6 +158,74 @@ 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_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())
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"},
Expand Down