Skip to content

Commit ac6c6f4

Browse files
feat(teams): deliver outbound files via data-URI activity attachments (#125)
* 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. * fix(teams): scope file delivery to post_message only (upstream fidelity) The initial port wired filesToAttachments into both post_message AND edit_message. Upstream vercel/chat wires it into postMessage + postChannelMessage only — editMessage never carries files. And chinchill delivers execution artifacts via a fresh post(), never by editing files into an existing message. Carrying files in edit_message was an unrequested divergence (and an edit_message+files test has no upstream counterpart, which the repo's verify_test_fidelity gate would flag). Revert edit_message to file-free, mirroring upstream. Replace the edit_message+file test with a fidelity guard asserting edit_message carries no file attachments. post_message file delivery (the actual parity fix) is unchanged. * test(teams): cover multi-file delivery + tighten skip-log Self-review (two adversarial reviewers) found: - MEDIUM: multi-file handling untested — mutation showed return attachments[:1] (drop all but first) and reversed() both passed green, so a regression that drops/reorders artifacts would merge undetected, defeating the parity goal. Added test_multiple_files_attached_in_order (N files -> N attachments, input order) + test_partial_skip_preserves_surviving_files (good/bad/good -> survivors in order). Mutation-verified: [:1] now FAILS the multi-file test. - LOW: the skip test's log assertion was hollow (post_message emits an unconditional 'send (message)' debug, so it passed even if the skip branch logged nothing). Now asserts the SPECIFIC 'unsupported data' skip log. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 09a47bc commit ac6c6f4

2 files changed

Lines changed: 253 additions & 5 deletions

File tree

src/chat_sdk/adapters/teams/adapter.py

Lines changed: 57 additions & 5 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

@@ -1127,7 +1171,15 @@ async def edit_message(
11271171
message_id: str,
11281172
message: AdapterPostableMessage,
11291173
) -> RawMessage:
1130-
"""Edit an existing Teams message."""
1174+
"""Edit an existing Teams message.
1175+
1176+
Note: file delivery is intentionally NOT wired here. Upstream
1177+
``vercel/chat`` ports ``filesToAttachments`` into ``postMessage`` and
1178+
``postChannelMessage`` only — ``editMessage`` does not carry files — and
1179+
chinchill delivers execution artifacts via a fresh ``post`` (never by
1180+
editing files into an existing message). Keeping ``edit_message``
1181+
file-free preserves upstream fidelity.
1182+
"""
11311183
decoded = self.decode_thread_id(thread_id)
11321184

11331185
card = extract_card(message)
@@ -1139,7 +1191,7 @@ async def edit_message(
11391191
{
11401192
"contentType": "application/vnd.microsoft.card.adaptive",
11411193
"content": adaptive_card,
1142-
}
1194+
},
11431195
],
11441196
}
11451197
else:

tests/test_teams_adapter.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,202 @@ 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_does_not_carry_files(self):
768+
"""Upstream fidelity: ``editMessage`` never delivers files (upstream wires
769+
``filesToAttachments`` into ``postMessage``/``postChannelMessage`` only), and
770+
chinchill delivers execution artifacts via a fresh ``post`` — never by editing
771+
files into an existing message. A ``PostableMarkdown`` carrying files must edit
772+
the text only, with no file attachments on the activity.
773+
"""
774+
from chat_sdk.types import FileUpload, PostableMarkdown
775+
776+
adapter = _make_adapter(app_id="test-app-id", logger=_make_logger())
777+
adapter._teams_update = AsyncMock()
778+
779+
message = PostableMarkdown(
780+
markdown="updated",
781+
files=[FileUpload(data=b"hello", filename="note.txt", mime_type="text/plain")],
782+
)
783+
result = await adapter.edit_message(self._thread_id(adapter), "edit-1", message)
784+
785+
payload = adapter._teams_update.call_args.args[2]
786+
assert "attachments" not in payload, (
787+
"edit_message must not carry file attachments — outbound file delivery is "
788+
f"post_message-only (upstream fidelity); got attachments={payload.get('attachments')!r}"
789+
)
790+
assert payload["text"] == "updated"
791+
assert result.id == "edit-1"
792+
793+
@pytest.mark.asyncio
794+
async def test_file_without_mime_type_defaults_to_octet_stream(self):
795+
from chat_sdk.types import FileUpload, PostableMarkdown
796+
797+
adapter = _make_adapter(app_id="test-app-id", logger=_make_logger())
798+
adapter._teams_send = AsyncMock(return_value={"id": "sent-3", "type": "message"})
799+
800+
message = PostableMarkdown(
801+
markdown="bin",
802+
files=[FileUpload(data=b"\x00\x01\x02", filename="blob.bin")],
803+
)
804+
await adapter.post_message(self._thread_id(adapter), message)
805+
806+
att = adapter._teams_send.call_args.args[1]["attachments"][0]
807+
assert att["contentType"] == "application/octet-stream"
808+
assert att["contentUrl"].startswith("data:application/octet-stream;base64,")
809+
810+
@pytest.mark.asyncio
811+
async def test_file_with_unresolvable_data_is_skipped(self):
812+
"""A FileUpload whose data is not bytes is skipped with a debug log.
813+
814+
Mirrors upstream's ``throwOnUnsupported: false`` followed by
815+
``if (!buffer) continue``. (The Python ``FileUpload`` has no
816+
``fetch_data`` field — it carries only inline ``data`` bytes — so the
817+
lazy-fetch case from the upstream interface collapses to this
818+
skip-unresolvable-bytes branch.)
819+
"""
820+
from chat_sdk.types import FileUpload, PostableMarkdown
821+
822+
logger = _make_logger()
823+
adapter = _make_adapter(app_id="test-app-id", logger=logger)
824+
adapter._teams_send = AsyncMock(return_value={"id": "sent-4", "type": "message"})
825+
826+
# data is a str, not bytes -> to_buffer returns None -> file skipped
827+
bad = FileUpload(data="not-bytes", filename="bad.txt", mime_type="text/plain") # type: ignore[arg-type]
828+
message = PostableMarkdown(markdown="text only", files=[bad])
829+
await adapter.post_message(self._thread_id(adapter), message)
830+
831+
payload = adapter._teams_send.call_args.args[1]
832+
# no attachments key added when every file was skipped
833+
assert "attachments" not in payload
834+
# assert the SPECIFIC skip log fired — not just that some debug log happened
835+
# (post_message emits an unconditional "send (message)" debug, so a bare
836+
# logger.debug.called check would pass even if the skip branch logged nothing).
837+
skip_logged = any(
838+
call.args and "skipping file with unsupported data" in str(call.args[0])
839+
for call in logger.debug.call_args_list
840+
)
841+
assert skip_logged, "a skipped file must emit the 'unsupported data' debug log"
842+
843+
@pytest.mark.asyncio
844+
async def test_multiple_files_attached_in_order(self):
845+
"""N files -> N attachments, in input order. Closes the gap where
846+
``return attachments[:1]`` (drop all but first) or a reorder would
847+
otherwise merge green — both directly defeat multi-artifact parity.
848+
"""
849+
from chat_sdk.types import FileUpload, PostableMarkdown
850+
851+
adapter = _make_adapter(app_id="test-app-id", logger=_make_logger())
852+
adapter._teams_send = AsyncMock(return_value={"id": "m", "type": "message"})
853+
854+
message = PostableMarkdown(
855+
markdown="three files",
856+
files=[
857+
FileUpload(data=b"aaa", filename="a.csv", mime_type="text/csv"),
858+
FileUpload(data=b"\x89PNG", filename="b.png", mime_type="image/png"),
859+
FileUpload(data=b"%PDF", filename="c.pdf", mime_type="application/pdf"),
860+
],
861+
)
862+
await adapter.post_message(self._thread_id(adapter), message)
863+
864+
attachments = adapter._teams_send.call_args.args[1]["attachments"]
865+
assert [a["name"] for a in attachments] == ["a.csv", "b.png", "c.pdf"]
866+
assert [a["contentType"] for a in attachments] == ["text/csv", "image/png", "application/pdf"]
867+
assert all(a["contentUrl"].startswith("data:") for a in attachments)
868+
869+
@pytest.mark.asyncio
870+
async def test_partial_skip_preserves_surviving_files_in_order(self):
871+
"""A good/bad/good batch drops only the unresolvable file; survivors keep
872+
input order. No single-file test covers partial-skip-with-survivors.
873+
"""
874+
from chat_sdk.types import FileUpload, PostableMarkdown
875+
876+
adapter = _make_adapter(app_id="test-app-id", logger=_make_logger())
877+
adapter._teams_send = AsyncMock(return_value={"id": "m", "type": "message"})
878+
879+
message = PostableMarkdown(
880+
markdown="good bad good",
881+
files=[
882+
FileUpload(data=b"first", filename="first.csv", mime_type="text/csv"),
883+
FileUpload(data="not-bytes", filename="bad.bin", mime_type="application/octet-stream"), # type: ignore[arg-type]
884+
FileUpload(data=b"third", filename="third.csv", mime_type="text/csv"),
885+
],
886+
)
887+
await adapter.post_message(self._thread_id(adapter), message)
888+
889+
attachments = adapter._teams_send.call_args.args[1]["attachments"]
890+
assert [a["name"] for a in attachments] == ["first.csv", "third.csv"]
891+
892+
697893
class TestDeleteMessage:
698894
@pytest.mark.asyncio
699895
async def test_deletes_without_error(self):

0 commit comments

Comments
 (0)