From 7e51b5d455b62bfcbea98a2e4a209eb6f5265aed Mon Sep 17 00:00:00 2001 From: tony-chinchill Date: Fri, 12 Jun 2026 18:54:04 -0700 Subject: [PATCH] fix(teams): surface pre-signed content.downloadUrl for chat file attachments TeamsAdapter._create_attachment read only the top-level contentUrl, which for a file uploaded in a personal/group chat points at the auth-required SharePoint/OneDrive item and returns 403 to an anonymous GET. The activity also carries content.downloadUrl, a short-lived pre-signed link that fetches with no auth header. Prefer it when present so the file is readable without granting Files.Read.All. Rebased onto main (0.4.29a3 line); CHANGELOG entry added, pyproject version bump left to the coordinating release PR per the unreleased-section convention. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 4 ++++ src/chat_sdk/adapters/teams/adapter.py | 8 +++++++- tests/test_teams_adapter.py | 28 ++++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 796c20e9..4b4ac48b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## 0.4.29a3 (unreleased) +### Fixes + +- **Teams chat file attachments now surface the pre-signed `content.downloadUrl`** — `TeamsAdapter._create_attachment` read only the top-level `contentUrl`, which for a file uploaded in a personal/group chat points at the auth-required SharePoint/OneDrive item and returns 403 to an anonymous GET. The activity also carries `content.downloadUrl`, a short-lived pre-signed link that fetches with no auth header. The adapter now prefers it when present, so the file is readable without granting `Files.Read.All`. Added `tests/test_teams_adapter.py::TestParseMessage::test_prefers_pre_signed_download_url_over_sharepoint_content_url`. Unblocks downstream consumers needing inbound Teams file reads. + ### Documentation alignment - **DM routing precedence docstrings** (vercel/chat#491, commit `67c1794`). The Python runtime already routed direct messages to `on_direct_message` handlers before subscribed-message, mention, and pattern handlers (`Chat._handle_incoming_message`, `chat.py:2154`), but `on_direct_message` and `Thread.subscribe` lacked docstrings clarifying that contract. Refreshed both to match upstream's #491 docstring rewrite, plus a new `tests/integration/test_dm_flow.py::TestDMRoutingDocs` group and a `test_dm_handler_runs_before_pattern_handler` regression test that pin the documented precedence (DM > pattern) the previous suite did not explicitly cover. No runtime behavior change. diff --git a/src/chat_sdk/adapters/teams/adapter.py b/src/chat_sdk/adapters/teams/adapter.py index b58e47d6..d7aded1e 100644 --- a/src/chat_sdk/adapters/teams/adapter.py +++ b/src/chat_sdk/adapters/teams/adapter.py @@ -924,7 +924,13 @@ def _create_attachment(self, att: dict[str, Any]) -> Attachment: elif content_type.startswith("audio/"): att_type = "audio" - url = att.get("contentUrl") + # Files uploaded in a personal/group chat arrive as a "download info" attachment + # whose ``content.downloadUrl`` is a short-lived pre-signed link that fetches with + # no auth header. The top-level ``contentUrl`` points at the SharePoint/OneDrive + # item and returns 403 to an anonymous GET, so prefer the pre-signed URL. + content = att.get("content") + download_url = content.get("downloadUrl") if isinstance(content, dict) else None + url = download_url or att.get("contentUrl") return Attachment( type=att_type, url=url, diff --git a/tests/test_teams_adapter.py b/tests/test_teams_adapter.py index 2479d656..ea676ca4 100644 --- a/tests/test_teams_adapter.py +++ b/tests/test_teams_adapter.py @@ -372,6 +372,34 @@ def test_attachment_stores_url_in_fetch_metadata(self): assert msg.attachments[0].fetch_metadata == {"url": "https://x.com/photo.jpg"} assert msg.attachments[0].fetch_data is not None + def test_prefers_pre_signed_download_url_over_sharepoint_content_url(self): + """A chat file upload arrives as a download-info attachment: ``content.downloadUrl`` + is a pre-signed link that fetches anonymously, while the top-level ``contentUrl`` + points at the auth-required SharePoint item and 403s. The adapter must surface the + pre-signed URL so the file is actually readable.""" + adapter = _make_adapter(app_id="test-app") + signed_url = "https://contoso.sharepoint.com/personal/alice/_layouts/download.aspx?UniqueId=abc&tempauth=SIGNED" + activity = { + "type": "message", + "id": "msg-dl", + "text": "", + "from": {"id": "user-1", "name": "Alice"}, + "conversation": {"id": "19:abc@thread.tacv2"}, + "serviceUrl": "https://smba.trafficmanager.net/teams/", + "attachments": [ + { + "contentType": "application/vnd.microsoft.teams.file.download.info", + "contentUrl": "https://contoso.sharepoint.com/personal/alice/Documents/report.csv", + "name": "report.csv", + "content": {"downloadUrl": signed_url, "uniqueId": "abc", "fileType": "csv"}, + }, + ], + } + msg = adapter.parse_message(activity) + assert msg.attachments[0].url == signed_url, ( + "must surface the pre-signed content.downloadUrl, not the auth-required SharePoint contentUrl" + ) + # --------------------------------------------------------------------------- # rehydrate_attachment