Skip to content

Commit 981d940

Browse files
feat(teams): deliver outbound files via data-URI activity attachments
Port upstream filesToAttachments (packages/adapter-teams/src/index.ts ~1006-1035) to Python. The Teams adapter previously dropped a Postable's .files entirely -- post_message/edit_message never called extract_files, so execution artifacts silently vanished. Adds TeamsAdapter._files_to_attachments: resolves each FileUpload's bytes via to_buffer(..., throw_on_unsupported=False), base64-encodes them, and builds a Bot Framework attachment {contentType, contentUrl: data:<mime>;base64,<b64>, name}. mime defaults to application/octet-stream; files whose data can't be resolved to bytes are skipped with a debug log (mirrors upstream's `if (!buffer) continue`). post_message and edit_message now call extract_files and attach the results: the adaptive-card branch appends file attachments after the card attachment; the text branch sets attachments to the file attachments when present. Adds TestFileAttachments (5 tests): text+file, card+file (both attachments present), edit_message+file, octet-stream default, and the skip-unresolvable-bytes branch. Bumps version to 0.4.29a3 and adds a CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 3ba6456 commit 981d940

4 files changed

Lines changed: 202 additions & 5 deletions

File tree

CHANGELOG.md

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

3+
## 0.4.29a3 (2026-05-29)
4+
5+
Python-only feature port. No upstream version change.
6+
7+
### Features
8+
9+
- **Teams now delivers outbound files via base64 data-URI activity attachments** — the Teams adapter previously dropped a `Postable`'s `.files` entirely: `post_message`/`edit_message` never called `extract_files`, so execution artifacts (CSVs, charts, etc.) silently vanished. This ports upstream `vercel/chat`'s `filesToAttachments` (`packages/adapter-teams/src/index.ts` lines ~1006-1035) to Python. A new `TeamsAdapter._files_to_attachments` resolves each `FileUpload`'s bytes via `to_buffer(..., throw_on_unsupported=False)`, base64-encodes them, and builds a Bot Framework attachment `{"contentType", "contentUrl": "data:<mime>;base64,<b64>", "name"}` (mime defaults to `application/octet-stream`); files whose data can't be resolved to bytes are skipped with a debug log (mirrors upstream's `if (!buffer) continue`). `post_message` and `edit_message` now call `extract_files` and attach the results: the adaptive-card branch appends file attachments after the card attachment, and the text branch sets `attachments` to the file attachments when present. This is the Teams half of outbound artifact parity and unblocks chinchill routing execution artifacts to Teams. **Caveat (inherited from upstream's design):** data-URI attachments inline the full file payload base64-encoded in the activity, so large files inflate the Bot Framework payload — the same size constraint upstream carries.
10+
11+
### Test quality
12+
13+
- Added 5 tests in `tests/test_teams_adapter.py` (`TestFileAttachments`): text+file, adaptive-card+file (both attachments present), `edit_message`+file, mime-type default to `application/octet-stream`, and the skip-unresolvable-bytes branch. Each delivery test round-trips or asserts the `data:<mime>;base64,...` `contentUrl`, `contentType`, and `name`.
14+
315
## 0.4.29a2 (2026-05-28)
416

517
Python-only fix. No upstream version change.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "chat-sdk"
3-
version = "0.4.29a2"
3+
version = "0.4.29a3"
44
description = "Multi-platform async chat SDK for Python — port of Vercel Chat"
55
keywords = [
66
"chat",

src/chat_sdk/adapters/teams/adapter.py

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
from chat_sdk.emoji import convert_emoji_placeholders
3232
from chat_sdk.errors import ChatNotImplementedError
3333
from chat_sdk.logger import ConsoleLogger, Logger
34-
from chat_sdk.shared.adapter_utils import extract_card
34+
from chat_sdk.shared.adapter_utils import extract_card, extract_files
35+
from chat_sdk.shared.buffer_utils import buffer_to_data_uri, to_buffer
3536
from chat_sdk.shared.errors import (
3637
AdapterPermissionError,
3738
AdapterRateLimitError,
@@ -49,6 +50,7 @@
4950
EmojiValue,
5051
FetchOptions,
5152
FetchResult,
53+
FileUpload,
5254
FormattedContent,
5355
LockScope,
5456
Message,
@@ -1038,6 +1040,40 @@ def _is_message_from_self(self, activity: dict[str, Any]) -> bool:
10381040
return True
10391041
return bool(from_id.endswith(f":{self._app_id}"))
10401042

1043+
async def _files_to_attachments(self, files: list[FileUpload]) -> list[dict[str, Any]]:
1044+
"""Convert ``FileUpload`` objects to Bot Framework data-URI attachments.
1045+
1046+
Python port of ``filesToAttachments`` in
1047+
``packages/adapter-teams/src/index.ts`` (lines ~1006-1035). Each file's
1048+
bytes are base64-encoded into a ``data:`` URI and attached as a
1049+
Bot Framework activity attachment. Files whose data cannot be resolved
1050+
to bytes are skipped (mirrors upstream's ``throwOnUnsupported: false``
1051+
followed by ``if (!buffer) continue``).
1052+
"""
1053+
attachments: list[dict[str, Any]] = []
1054+
1055+
for file in files:
1056+
buffer = await to_buffer(file.data, "teams", throw_on_unsupported=False)
1057+
if buffer is None:
1058+
self._logger.debug(
1059+
"Teams API: skipping file with unsupported data",
1060+
{"filename": file.filename},
1061+
)
1062+
continue
1063+
1064+
mime_type = file.mime_type or "application/octet-stream"
1065+
data_uri = buffer_to_data_uri(buffer, mime_type)
1066+
1067+
attachments.append(
1068+
{
1069+
"contentType": mime_type,
1070+
"contentUrl": data_uri,
1071+
"name": file.filename,
1072+
}
1073+
)
1074+
1075+
return attachments
1076+
10411077
async def post_message(
10421078
self,
10431079
thread_id: str,
@@ -1046,6 +1082,9 @@ async def post_message(
10461082
"""Post a message to a Teams conversation."""
10471083
decoded = self.decode_thread_id(thread_id)
10481084

1085+
files = extract_files(message)
1086+
file_attachments = await self._files_to_attachments(files) if files else []
1087+
10491088
card = extract_card(message)
10501089
if card:
10511090
adaptive_card = card_to_adaptive_card(card)
@@ -1055,14 +1094,16 @@ async def post_message(
10551094
{
10561095
"contentType": "application/vnd.microsoft.card.adaptive",
10571096
"content": adaptive_card,
1058-
}
1097+
},
1098+
*file_attachments,
10591099
],
10601100
}
10611101

10621102
self._logger.debug(
10631103
"Teams API: send (adaptive card)",
10641104
{
10651105
"conversationId": decoded.conversation_id,
1106+
"fileCount": len(file_attachments),
10661107
},
10671108
)
10681109

@@ -1089,17 +1130,20 @@ async def post_message(
10891130
"teams",
10901131
)
10911132

1092-
activity_payload = {
1133+
activity_payload: dict[str, Any] = {
10931134
"type": "message",
10941135
"text": text,
10951136
"textFormat": "markdown",
10961137
}
1138+
if file_attachments:
1139+
activity_payload["attachments"] = file_attachments
10971140

10981141
self._logger.debug(
10991142
"Teams API: send (message)",
11001143
{
11011144
"conversationId": decoded.conversation_id,
11021145
"textLength": len(text),
1146+
"fileCount": len(file_attachments),
11031147
},
11041148
)
11051149

@@ -1130,7 +1174,11 @@ async def edit_message(
11301174
"""Edit an existing Teams message."""
11311175
decoded = self.decode_thread_id(thread_id)
11321176

1177+
files = extract_files(message)
1178+
file_attachments = await self._files_to_attachments(files) if files else []
1179+
11331180
card = extract_card(message)
1181+
activity_payload: dict[str, Any]
11341182
if card:
11351183
adaptive_card = card_to_adaptive_card(card)
11361184
activity_payload = {
@@ -1139,7 +1187,8 @@ async def edit_message(
11391187
{
11401188
"contentType": "application/vnd.microsoft.card.adaptive",
11411189
"content": adaptive_card,
1142-
}
1190+
},
1191+
*file_attachments,
11431192
],
11441193
}
11451194
else:
@@ -1152,6 +1201,8 @@ async def edit_message(
11521201
"text": text,
11531202
"textFormat": "markdown",
11541203
}
1204+
if file_attachments:
1205+
activity_payload["attachments"] = file_attachments
11551206

11561207
self._logger.debug(
11571208
"Teams API: updateActivity",

tests/test_teams_adapter.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,140 @@ async def test_updates_and_returns(self):
694694
adapter._teams_update.assert_called_once()
695695

696696

697+
class TestFileAttachments:
698+
"""Outbound file delivery via base64 data-URI activity attachments.
699+
700+
Ports ``filesToAttachments`` from
701+
``packages/adapter-teams/src/index.ts`` (lines ~1006-1035) and its use in
702+
``postMessage``/``editMessage``.
703+
"""
704+
705+
@staticmethod
706+
def _thread_id(adapter: TeamsAdapter) -> str:
707+
return adapter.encode_thread_id(
708+
TeamsThreadId(
709+
conversation_id="19:abc@thread.tacv2",
710+
service_url="https://smba.trafficmanager.net/teams/",
711+
)
712+
)
713+
714+
@pytest.mark.asyncio
715+
async def test_text_message_with_file(self):
716+
from chat_sdk.types import FileUpload, PostableMarkdown
717+
718+
adapter = _make_adapter(app_id="test-app-id", logger=_make_logger())
719+
adapter._teams_send = AsyncMock(return_value={"id": "sent-1", "type": "message"})
720+
721+
message = PostableMarkdown(
722+
markdown="here is your report",
723+
files=[FileUpload(data=b"a,b,c\n1,2,3\n", filename="report.csv", mime_type="text/csv")],
724+
)
725+
result = await adapter.post_message(self._thread_id(adapter), message)
726+
727+
payload = adapter._teams_send.call_args.args[1]
728+
attachments = payload["attachments"]
729+
assert len(attachments) == 1
730+
att = attachments[0]
731+
assert att["contentType"] == "text/csv"
732+
assert att["name"] == "report.csv"
733+
assert att["contentUrl"].startswith("data:text/csv;base64,")
734+
# round-trip the base64 payload back to the original bytes
735+
import base64
736+
737+
b64 = att["contentUrl"].split("base64,", 1)[1]
738+
assert base64.b64decode(b64) == b"a,b,c\n1,2,3\n"
739+
# the data-URI attachment is also recorded on the returned raw activity
740+
assert result.raw["attachments"][0]["name"] == "report.csv"
741+
742+
@pytest.mark.asyncio
743+
async def test_card_message_with_file(self):
744+
from chat_sdk.cards import Card
745+
from chat_sdk.types import FileUpload, PostableCard
746+
747+
adapter = _make_adapter(app_id="test-app-id", logger=_make_logger())
748+
adapter._teams_send = AsyncMock(return_value={"id": "sent-2", "type": "message"})
749+
750+
message = PostableCard(
751+
card=Card(title="Results"),
752+
files=[FileUpload(data=b"\x89PNG\r\n", filename="chart.png", mime_type="image/png")],
753+
)
754+
await adapter.post_message(self._thread_id(adapter), message)
755+
756+
payload = adapter._teams_send.call_args.args[1]
757+
attachments = payload["attachments"]
758+
# adaptive card attachment AND the file attachment both present
759+
assert len(attachments) == 2
760+
assert attachments[0]["contentType"] == "application/vnd.microsoft.card.adaptive"
761+
file_att = attachments[1]
762+
assert file_att["contentType"] == "image/png"
763+
assert file_att["name"] == "chart.png"
764+
assert file_att["contentUrl"].startswith("data:image/png;base64,")
765+
766+
@pytest.mark.asyncio
767+
async def test_edit_message_with_file(self):
768+
from chat_sdk.types import FileUpload, PostableMarkdown
769+
770+
adapter = _make_adapter(app_id="test-app-id", logger=_make_logger())
771+
adapter._teams_update = AsyncMock()
772+
773+
message = PostableMarkdown(
774+
markdown="updated",
775+
files=[FileUpload(data=b"hello", filename="note.txt", mime_type="text/plain")],
776+
)
777+
result = await adapter.edit_message(self._thread_id(adapter), "edit-1", message)
778+
779+
payload = adapter._teams_update.call_args.args[2]
780+
attachments = payload["attachments"]
781+
assert len(attachments) == 1
782+
assert attachments[0]["contentType"] == "text/plain"
783+
assert attachments[0]["name"] == "note.txt"
784+
assert attachments[0]["contentUrl"].startswith("data:text/plain;base64,")
785+
assert result.id == "edit-1"
786+
787+
@pytest.mark.asyncio
788+
async def test_file_without_mime_type_defaults_to_octet_stream(self):
789+
from chat_sdk.types import FileUpload, PostableMarkdown
790+
791+
adapter = _make_adapter(app_id="test-app-id", logger=_make_logger())
792+
adapter._teams_send = AsyncMock(return_value={"id": "sent-3", "type": "message"})
793+
794+
message = PostableMarkdown(
795+
markdown="bin",
796+
files=[FileUpload(data=b"\x00\x01\x02", filename="blob.bin")],
797+
)
798+
await adapter.post_message(self._thread_id(adapter), message)
799+
800+
att = adapter._teams_send.call_args.args[1]["attachments"][0]
801+
assert att["contentType"] == "application/octet-stream"
802+
assert att["contentUrl"].startswith("data:application/octet-stream;base64,")
803+
804+
@pytest.mark.asyncio
805+
async def test_file_with_unresolvable_data_is_skipped(self):
806+
"""A FileUpload whose data is not bytes is skipped with a debug log.
807+
808+
Mirrors upstream's ``throwOnUnsupported: false`` followed by
809+
``if (!buffer) continue``. (The Python ``FileUpload`` has no
810+
``fetch_data`` field — it carries only inline ``data`` bytes — so the
811+
lazy-fetch case from the upstream interface collapses to this
812+
skip-unresolvable-bytes branch.)
813+
"""
814+
from chat_sdk.types import FileUpload, PostableMarkdown
815+
816+
logger = _make_logger()
817+
adapter = _make_adapter(app_id="test-app-id", logger=logger)
818+
adapter._teams_send = AsyncMock(return_value={"id": "sent-4", "type": "message"})
819+
820+
# data is a str, not bytes -> to_buffer returns None -> file skipped
821+
bad = FileUpload(data="not-bytes", filename="bad.txt", mime_type="text/plain") # type: ignore[arg-type]
822+
message = PostableMarkdown(markdown="text only", files=[bad])
823+
await adapter.post_message(self._thread_id(adapter), message)
824+
825+
payload = adapter._teams_send.call_args.args[1]
826+
# no attachments key added when every file was skipped
827+
assert "attachments" not in payload
828+
assert logger.debug.called
829+
830+
697831
class TestDeleteMessage:
698832
@pytest.mark.asyncio
699833
async def test_deletes_without_error(self):

0 commit comments

Comments
 (0)