Skip to content

Commit a4e9235

Browse files
feat(attachments): port rehydrateAttachment adapter hook + Thread serialization
Upstream Adapter.rehydrateAttachment rebuilds the fetch_data download closure after a JSON roundtrip through the state adapter — essential for queue/debounce concurrency strategies, where entries pass through JSON.stringify and lose any callable fields. This PR ports the hook to Python: Adapter gains an optional rehydrate_attachment method (default no-op on BaseAdapter), Attachment gains a serializable fetch_metadata dict, and Chat._rehydrate_message now threads the active adapter and invokes the hook on any attachment whose fetch_data was stripped. Per-adapter implementations land on Slack (url + teamId), Teams (url), Google Chat (resourceName + url), Telegram (fileId), and WhatsApp (mediaId); Discord, GitHub and Linear intentionally do not implement it (upstream parity — they use public URLs or have no file attachments). Closes 3 [concurrency: queue attachment rehydration] fidelity gaps. Refs #52. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d23b6d9 commit a4e9235

14 files changed

Lines changed: 885 additions & 100 deletions

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,24 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Added
6+
7+
- **`Adapter.rehydrate_attachment` hook + `Attachment.fetch_metadata`** (#52):
8+
port of the upstream `rehydrateAttachment` adapter hook that restores
9+
`fetch_data` closures after JSON serialization. `Chat._rehydrate_message`
10+
now accepts the active adapter and invokes the hook on every attachment
11+
that lost its callback during a queue/debounce roundtrip. `Attachment`
12+
gains a serializable `fetch_metadata: dict[str, str] | None` field that
13+
persists adapter-specific identifiers (Slack `url` + `teamId`, Teams
14+
`url`, Google Chat `resourceName` + `url`, Telegram `fileId`, WhatsApp
15+
`mediaId`) through `to_json` / `from_json`. Implementations land on
16+
Slack, Teams, Google Chat, Telegram, and WhatsApp; Discord / GitHub /
17+
Linear do not (upstream parity — those platforms either use public URLs
18+
or carry no file attachments). Closes the 3
19+
`[concurrency: queue attachment rehydration]` fidelity gaps in
20+
`chat.test.ts`.
21+
322
## 0.4.26.1 (2026-04-23)
423

524
Python-only follow-up on `0.4.26`. Still alpha — APIs may change.

src/chat_sdk/adapters/google_chat/adapter.py

Lines changed: 77 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2612,53 +2612,92 @@ def _create_attachment(self, att: dict[str, Any]) -> Attachment:
26122612
elif content_type.startswith("audio/"):
26132613
att_type = "audio"
26142614

2615-
# Build fetchData closure
2615+
fetch_meta: dict[str, str] = {}
2616+
if resource_name:
2617+
fetch_meta["resourceName"] = resource_name
2618+
if url:
2619+
fetch_meta["url"] = url
2620+
26162621
fetch_data: Callable[[], Awaitable[bytes]] | None = None
26172622
if resource_name or url:
2618-
adapter = self
2619-
2620-
async def _fetch_data() -> bytes:
2621-
# Prefer media.download API
2622-
if resource_name:
2623-
token = await adapter._get_access_token()
2624-
download_url = f"https://chat.googleapis.com/v1/media/{resource_name}?alt=media"
2625-
session = await adapter._get_http_session()
2626-
async with session.get(
2627-
download_url,
2628-
headers={"Authorization": f"Bearer {token}"},
2629-
) as response:
2630-
if response.status >= 400:
2631-
raise NetworkError(
2632-
"gchat",
2633-
f"Failed to download media: {response.status}",
2634-
)
2635-
return await response.read()
2636-
2637-
# Fallback to direct URL fetch (downloadUri)
2638-
if url:
2639-
token = await adapter._get_access_token()
2640-
session = await adapter._get_http_session()
2641-
async with session.get(
2642-
url,
2643-
headers={"Authorization": f"Bearer {token}"},
2644-
) as response:
2645-
if response.status >= 400:
2646-
raise NetworkError(
2647-
"gchat",
2648-
f"Failed to fetch file: {response.status}",
2649-
)
2650-
return await response.read()
2651-
2652-
raise AuthenticationError("gchat", "Cannot fetch file: no URL or resource name")
2653-
2654-
fetch_data = _fetch_data
2623+
fetch_data = self._build_gchat_fetch_data(resource_name, url)
26552624

26562625
return Attachment(
26572626
type=att_type, # type: ignore[arg-type]
26582627
url=url,
26592628
name=att.get("contentName"),
26602629
mime_type=att.get("contentType"),
26612630
fetch_data=fetch_data,
2631+
fetch_metadata=fetch_meta or None,
2632+
)
2633+
2634+
def _build_gchat_fetch_data(
2635+
self,
2636+
resource_name: str | None,
2637+
url: str | None,
2638+
) -> Callable[[], Awaitable[bytes]]:
2639+
"""Build a lazy ``fetch_data`` closure for a Google Chat attachment."""
2640+
adapter = self
2641+
2642+
async def _fetch_data() -> bytes:
2643+
# Prefer media.download API
2644+
if resource_name:
2645+
token = await adapter._get_access_token()
2646+
download_url = f"https://chat.googleapis.com/v1/media/{resource_name}?alt=media"
2647+
session = await adapter._get_http_session()
2648+
async with session.get(
2649+
download_url,
2650+
headers={"Authorization": f"Bearer {token}"},
2651+
) as response:
2652+
if response.status >= 400:
2653+
raise NetworkError(
2654+
"gchat",
2655+
f"Failed to download media: {response.status}",
2656+
)
2657+
return await response.read()
2658+
2659+
# Fallback to direct URL fetch (downloadUri)
2660+
if url:
2661+
token = await adapter._get_access_token()
2662+
session = await adapter._get_http_session()
2663+
async with session.get(
2664+
url,
2665+
headers={"Authorization": f"Bearer {token}"},
2666+
) as response:
2667+
if response.status >= 400:
2668+
raise NetworkError(
2669+
"gchat",
2670+
f"Failed to fetch file: {response.status}",
2671+
)
2672+
return await response.read()
2673+
2674+
raise AuthenticationError("gchat", "Cannot fetch file: no URL or resource name")
2675+
2676+
return _fetch_data
2677+
2678+
def rehydrate_attachment(self, attachment: Attachment) -> Attachment:
2679+
"""Reconstruct ``fetch_data`` on a deserialized Google Chat attachment.
2680+
2681+
Pulls ``resourceName`` (preferred, used with media.download API) and
2682+
``url`` (fallback) from ``fetch_metadata``. Returns the attachment
2683+
unchanged when neither identifier is present.
2684+
"""
2685+
meta = attachment.fetch_metadata or {}
2686+
resource_name = meta.get("resourceName")
2687+
url = meta.get("url") or attachment.url
2688+
if not (resource_name or url):
2689+
return attachment
2690+
return Attachment(
2691+
type=attachment.type,
2692+
url=attachment.url,
2693+
name=attachment.name,
2694+
mime_type=attachment.mime_type,
2695+
size=attachment.size,
2696+
width=attachment.width,
2697+
height=attachment.height,
2698+
data=attachment.data,
2699+
fetch_data=self._build_gchat_fetch_data(resource_name, url),
2700+
fetch_metadata=attachment.fetch_metadata,
26622701
)
26632702

26642703
# =========================================================================

src/chat_sdk/adapters/slack/adapter.py

Lines changed: 88 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1679,7 +1679,10 @@ async def _parse_slack_message(
16791679
edited=bool(event.get("edited")),
16801680
edited_at=edited_at,
16811681
),
1682-
attachments=[self._create_attachment(f) for f in event.get("files", [])],
1682+
attachments=[
1683+
self._create_attachment(f, team_id=event.get("team") or event.get("team_id"))
1684+
for f in event.get("files", [])
1685+
],
16831686
links=self._extract_links(event),
16841687
)
16851688

@@ -1721,12 +1724,21 @@ def _parse_slack_message_sync(self, event: dict[str, Any], thread_id: str) -> Me
17211724
edited=bool(event.get("edited")),
17221725
edited_at=edited_at,
17231726
),
1724-
attachments=[self._create_attachment(f) for f in event.get("files", [])],
1727+
attachments=[
1728+
self._create_attachment(f, team_id=event.get("team") or event.get("team_id"))
1729+
for f in event.get("files", [])
1730+
],
17251731
links=self._extract_links(event),
17261732
)
17271733

1728-
def _create_attachment(self, file: dict[str, Any]) -> Attachment:
1729-
"""Create an Attachment from a Slack file object."""
1734+
def _create_attachment(self, file: dict[str, Any], team_id: str | None = None) -> Attachment:
1735+
"""Create an Attachment from a Slack file object.
1736+
1737+
``team_id`` identifies the workspace the file belongs to and is
1738+
stored in ``fetch_metadata`` so :meth:`rehydrate_attachment` can
1739+
rebuild the download closure (with workspace-specific token) after
1740+
the queue/debounce path JSON-serializes the message.
1741+
"""
17301742
url = file.get("url_private")
17311743
# Capture token at creation time (during webhook processing)
17321744
bot_token = self._get_token()
@@ -1741,18 +1753,13 @@ def _create_attachment(self, file: dict[str, Any]) -> Attachment:
17411753
att_type = "audio"
17421754

17431755
async def fetch_data() -> bytes:
1744-
import httpx
1745-
1746-
async with httpx.AsyncClient() as http:
1747-
resp = await http.get(url, headers={"Authorization": f"Bearer {bot_token}"}) # type: ignore[arg-type]
1748-
resp.raise_for_status()
1749-
content_type = resp.headers.get("content-type", "")
1750-
if "text/html" in content_type:
1751-
raise RuntimeError(
1752-
"Failed to download file from Slack: received HTML login page. "
1753-
'Ensure your Slack app has the "files:read" OAuth scope.'
1754-
)
1755-
return resp.content
1756+
return await self._fetch_slack_file(url, bot_token) # type: ignore[arg-type]
1757+
1758+
fetch_meta: dict[str, str] = {}
1759+
if url:
1760+
fetch_meta["url"] = url
1761+
if team_id:
1762+
fetch_meta["teamId"] = team_id
17561763

17571764
return Attachment(
17581765
type=att_type, # type: ignore[arg-type]
@@ -1763,6 +1770,71 @@ async def fetch_data() -> bytes:
17631770
width=file.get("original_w"),
17641771
height=file.get("original_h"),
17651772
fetch_data=fetch_data if url else None,
1773+
fetch_metadata=fetch_meta or None,
1774+
)
1775+
1776+
async def _fetch_slack_file(self, url: str, token: str) -> bytes:
1777+
"""Download a file from a Slack ``url_private`` endpoint.
1778+
1779+
Shared by :meth:`_create_attachment` (direct fetch closure) and
1780+
:meth:`rehydrate_attachment` (reconstructed closure after JSON
1781+
roundtrip).
1782+
"""
1783+
import httpx
1784+
1785+
async with httpx.AsyncClient() as http:
1786+
resp = await http.get(url, headers={"Authorization": f"Bearer {token}"})
1787+
resp.raise_for_status()
1788+
content_type = resp.headers.get("content-type", "")
1789+
if "text/html" in content_type:
1790+
raise RuntimeError(
1791+
"Failed to download file from Slack: received HTML login page. "
1792+
'Ensure your Slack app has the "files:read" OAuth scope.'
1793+
)
1794+
return resp.content
1795+
1796+
def rehydrate_attachment(self, attachment: Attachment) -> Attachment:
1797+
"""Reconstruct ``fetch_data`` on a deserialized Slack attachment.
1798+
1799+
Matches the upstream TS implementation: looks up the download URL
1800+
(and optional ``teamId`` for multi-workspace installations) from
1801+
``attachment.fetch_metadata``, and rebuilds a ``fetch_data`` closure
1802+
that resolves the workspace-specific bot token at call time.
1803+
1804+
Returns the attachment unchanged when no URL is available.
1805+
"""
1806+
meta = attachment.fetch_metadata or {}
1807+
url = meta.get("url") or attachment.url
1808+
team_id = meta.get("teamId")
1809+
if not url:
1810+
return attachment
1811+
1812+
adapter = self
1813+
1814+
async def fetch_data() -> bytes:
1815+
if team_id:
1816+
installation = await adapter.get_installation(team_id)
1817+
if installation is None:
1818+
raise AuthenticationError(
1819+
"slack",
1820+
f"Installation not found for team {team_id}",
1821+
)
1822+
token = installation.bot_token
1823+
else:
1824+
token = adapter._get_token()
1825+
return await adapter._fetch_slack_file(url, token)
1826+
1827+
return Attachment(
1828+
type=attachment.type,
1829+
url=attachment.url,
1830+
name=attachment.name,
1831+
mime_type=attachment.mime_type,
1832+
size=attachment.size,
1833+
width=attachment.width,
1834+
height=attachment.height,
1835+
data=attachment.data,
1836+
fetch_data=fetch_data,
1837+
fetch_metadata=attachment.fetch_metadata,
17661838
)
17671839

17681840
def _is_message_from_self(self, event: dict[str, Any]) -> bool:

src/chat_sdk/adapters/teams/adapter.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import json
1515
import os
1616
import re
17+
from collections.abc import Awaitable, Callable
1718
from datetime import datetime, timezone
1819
from typing import Any, Literal, NoReturn
1920

@@ -580,11 +581,52 @@ def _create_attachment(self, att: dict[str, Any]) -> Attachment:
580581
elif content_type.startswith("audio/"):
581582
att_type = "audio"
582583

584+
url = att.get("contentUrl")
583585
return Attachment(
584586
type=att_type,
585-
url=att.get("contentUrl"),
587+
url=url,
586588
name=att.get("name"),
587589
mime_type=content_type or None,
590+
fetch_metadata={"url": url} if url else None,
591+
fetch_data=self._build_teams_fetch_data(url) if url else None,
592+
)
593+
594+
def _build_teams_fetch_data(self, url: str) -> Callable[[], Awaitable[bytes]]:
595+
"""Build a lazy ``fetch_data`` closure for a Teams file URL."""
596+
597+
async def fetch_data() -> bytes:
598+
import httpx
599+
600+
async with httpx.AsyncClient() as http:
601+
resp = await http.get(url)
602+
resp.raise_for_status()
603+
return resp.content
604+
605+
return fetch_data
606+
607+
def rehydrate_attachment(self, attachment: Attachment) -> Attachment:
608+
"""Reconstruct ``fetch_data`` on a deserialized Teams attachment.
609+
610+
Teams uses public file URLs (signed by the Graph API), so all we
611+
need to rebuild the download closure is the URL — either from
612+
``fetch_metadata["url"]`` or the attachment's top-level ``url``.
613+
Returns the attachment unchanged when no URL is available.
614+
"""
615+
meta = attachment.fetch_metadata or {}
616+
url = meta.get("url") or attachment.url
617+
if not url:
618+
return attachment
619+
return Attachment(
620+
type=attachment.type,
621+
url=attachment.url,
622+
name=attachment.name,
623+
mime_type=attachment.mime_type,
624+
size=attachment.size,
625+
width=attachment.width,
626+
height=attachment.height,
627+
data=attachment.data,
628+
fetch_data=self._build_teams_fetch_data(url),
629+
fetch_metadata=attachment.fetch_metadata,
588630
)
589631

590632
def _is_message_from_self(self, activity: dict[str, Any]) -> bool:

src/chat_sdk/adapters/telegram/adapter.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1365,6 +1365,32 @@ def create_attachment(
13651365
name=name,
13661366
mime_type=mime_type,
13671367
fetch_data=lambda _fid=file_id: self.download_file(_fid),
1368+
fetch_metadata={"fileId": file_id},
1369+
)
1370+
1371+
def rehydrate_attachment(self, attachment: Attachment) -> Attachment:
1372+
"""Reconstruct ``fetch_data`` on a deserialized Telegram attachment.
1373+
1374+
Pulls ``fileId`` from ``fetch_metadata`` and rebuilds the lazy
1375+
``download_file`` closure. Returns the attachment unchanged when
1376+
no file ID is present (e.g. a pre-serialized attachment that did
1377+
not originate from this adapter).
1378+
"""
1379+
meta = attachment.fetch_metadata or {}
1380+
file_id = meta.get("fileId")
1381+
if not file_id:
1382+
return attachment
1383+
return Attachment(
1384+
type=attachment.type,
1385+
url=attachment.url,
1386+
name=attachment.name,
1387+
mime_type=attachment.mime_type,
1388+
size=attachment.size,
1389+
width=attachment.width,
1390+
height=attachment.height,
1391+
data=attachment.data,
1392+
fetch_data=lambda _fid=file_id: self.download_file(_fid),
1393+
fetch_metadata=attachment.fetch_metadata,
13681394
)
13691395

13701396
async def download_file(self, file_id: str) -> bytes:

0 commit comments

Comments
 (0)