Skip to content
Merged
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
62 changes: 57 additions & 5 deletions src/chat_sdk/adapters/teams/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
from chat_sdk.emoji import convert_emoji_placeholders
from chat_sdk.errors import ChatNotImplementedError
from chat_sdk.logger import ConsoleLogger, Logger
from chat_sdk.shared.adapter_utils import extract_card
from chat_sdk.shared.adapter_utils import extract_card, extract_files
from chat_sdk.shared.buffer_utils import buffer_to_data_uri, to_buffer
from chat_sdk.shared.errors import (
AdapterPermissionError,
AdapterRateLimitError,
Expand All @@ -49,6 +50,7 @@
EmojiValue,
FetchOptions,
FetchResult,
FileUpload,
FormattedContent,
LockScope,
Message,
Expand Down Expand Up @@ -1038,6 +1040,40 @@ def _is_message_from_self(self, activity: dict[str, Any]) -> bool:
return True
return bool(from_id.endswith(f":{self._app_id}"))

async def _files_to_attachments(self, files: list[FileUpload]) -> list[dict[str, Any]]:
"""Convert ``FileUpload`` objects to Bot Framework data-URI attachments.

Python port of ``filesToAttachments`` in
``packages/adapter-teams/src/index.ts`` (lines ~1006-1035). Each file's
bytes are base64-encoded into a ``data:`` URI and attached as a
Bot Framework activity attachment. Files whose data cannot be resolved
to bytes are skipped (mirrors upstream's ``throwOnUnsupported: false``
followed by ``if (!buffer) continue``).
"""
attachments: list[dict[str, Any]] = []

for file in files:
buffer = await to_buffer(file.data, "teams", throw_on_unsupported=False)
if buffer is None:
self._logger.debug(
"Teams API: skipping file with unsupported data",
{"filename": file.filename},
)
continue

mime_type = file.mime_type or "application/octet-stream"
data_uri = buffer_to_data_uri(buffer, mime_type)

attachments.append(
{
"contentType": mime_type,
"contentUrl": data_uri,
"name": file.filename,
}
)

return attachments

async def post_message(
self,
thread_id: str,
Expand All @@ -1046,6 +1082,9 @@ async def post_message(
"""Post a message to a Teams conversation."""
decoded = self.decode_thread_id(thread_id)

files = extract_files(message)
file_attachments = await self._files_to_attachments(files) if files else []

card = extract_card(message)
if card:
adaptive_card = card_to_adaptive_card(card)
Expand All @@ -1055,14 +1094,16 @@ async def post_message(
{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": adaptive_card,
}
},
*file_attachments,
],
}

self._logger.debug(
"Teams API: send (adaptive card)",
{
"conversationId": decoded.conversation_id,
"fileCount": len(file_attachments),
},
)

Expand All @@ -1089,17 +1130,20 @@ async def post_message(
"teams",
)

activity_payload = {
activity_payload: dict[str, Any] = {
"type": "message",
"text": text,
"textFormat": "markdown",
}
if file_attachments:
activity_payload["attachments"] = file_attachments

self._logger.debug(
"Teams API: send (message)",
{
"conversationId": decoded.conversation_id,
"textLength": len(text),
"fileCount": len(file_attachments),
},
)

Expand Down Expand Up @@ -1127,7 +1171,15 @@ async def edit_message(
message_id: str,
message: AdapterPostableMessage,
) -> RawMessage:
"""Edit an existing Teams message."""
"""Edit an existing Teams message.

Note: file delivery is intentionally NOT wired here. Upstream
``vercel/chat`` ports ``filesToAttachments`` into ``postMessage`` and
``postChannelMessage`` only — ``editMessage`` does not carry files — and
chinchill delivers execution artifacts via a fresh ``post`` (never by
editing files into an existing message). Keeping ``edit_message``
file-free preserves upstream fidelity.
"""
decoded = self.decode_thread_id(thread_id)

card = extract_card(message)
Expand All @@ -1139,7 +1191,7 @@ async def edit_message(
{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": adaptive_card,
}
},
],
}
else:
Expand Down
196 changes: 196 additions & 0 deletions tests/test_teams_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -694,6 +694,202 @@ async def test_updates_and_returns(self):
adapter._teams_update.assert_called_once()


class TestFileAttachments:
"""Outbound file delivery via base64 data-URI activity attachments.

Ports ``filesToAttachments`` from
``packages/adapter-teams/src/index.ts`` (lines ~1006-1035) and its use in
``postMessage``/``editMessage``.
"""

@staticmethod
def _thread_id(adapter: TeamsAdapter) -> str:
return adapter.encode_thread_id(
TeamsThreadId(
conversation_id="19:abc@thread.tacv2",
service_url="https://smba.trafficmanager.net/teams/",
)
)

@pytest.mark.asyncio
async def test_text_message_with_file(self):
from chat_sdk.types import FileUpload, PostableMarkdown

adapter = _make_adapter(app_id="test-app-id", logger=_make_logger())
adapter._teams_send = AsyncMock(return_value={"id": "sent-1", "type": "message"})

message = PostableMarkdown(
markdown="here is your report",
files=[FileUpload(data=b"a,b,c\n1,2,3\n", filename="report.csv", mime_type="text/csv")],
)
result = await adapter.post_message(self._thread_id(adapter), message)

payload = adapter._teams_send.call_args.args[1]
attachments = payload["attachments"]
assert len(attachments) == 1
att = attachments[0]
assert att["contentType"] == "text/csv"
assert att["name"] == "report.csv"
assert att["contentUrl"].startswith("data:text/csv;base64,")
# round-trip the base64 payload back to the original bytes
import base64

b64 = att["contentUrl"].split("base64,", 1)[1]
assert base64.b64decode(b64) == b"a,b,c\n1,2,3\n"
# the data-URI attachment is also recorded on the returned raw activity
assert result.raw["attachments"][0]["name"] == "report.csv"

@pytest.mark.asyncio
async def test_card_message_with_file(self):
from chat_sdk.cards import Card
from chat_sdk.types import FileUpload, PostableCard

adapter = _make_adapter(app_id="test-app-id", logger=_make_logger())
adapter._teams_send = AsyncMock(return_value={"id": "sent-2", "type": "message"})

message = PostableCard(
card=Card(title="Results"),
files=[FileUpload(data=b"\x89PNG\r\n", filename="chart.png", mime_type="image/png")],
)
await adapter.post_message(self._thread_id(adapter), message)

payload = adapter._teams_send.call_args.args[1]
attachments = payload["attachments"]
# adaptive card attachment AND the file attachment both present
assert len(attachments) == 2
assert attachments[0]["contentType"] == "application/vnd.microsoft.card.adaptive"
file_att = attachments[1]
assert file_att["contentType"] == "image/png"
assert file_att["name"] == "chart.png"
assert file_att["contentUrl"].startswith("data:image/png;base64,")

@pytest.mark.asyncio
async def test_edit_message_does_not_carry_files(self):
"""Upstream fidelity: ``editMessage`` never delivers files (upstream wires
``filesToAttachments`` into ``postMessage``/``postChannelMessage`` only), and
chinchill delivers execution artifacts via a fresh ``post`` — never by editing
files into an existing message. A ``PostableMarkdown`` carrying files must edit
the text only, with no file attachments on the activity.
"""
from chat_sdk.types import FileUpload, PostableMarkdown

adapter = _make_adapter(app_id="test-app-id", logger=_make_logger())
adapter._teams_update = AsyncMock()

message = PostableMarkdown(
markdown="updated",
files=[FileUpload(data=b"hello", filename="note.txt", mime_type="text/plain")],
)
result = await adapter.edit_message(self._thread_id(adapter), "edit-1", message)

payload = adapter._teams_update.call_args.args[2]
assert "attachments" not in payload, (
"edit_message must not carry file attachments — outbound file delivery is "
f"post_message-only (upstream fidelity); got attachments={payload.get('attachments')!r}"
)
assert payload["text"] == "updated"
assert result.id == "edit-1"

@pytest.mark.asyncio
async def test_file_without_mime_type_defaults_to_octet_stream(self):
from chat_sdk.types import FileUpload, PostableMarkdown

adapter = _make_adapter(app_id="test-app-id", logger=_make_logger())
adapter._teams_send = AsyncMock(return_value={"id": "sent-3", "type": "message"})

message = PostableMarkdown(
markdown="bin",
files=[FileUpload(data=b"\x00\x01\x02", filename="blob.bin")],
)
await adapter.post_message(self._thread_id(adapter), message)

att = adapter._teams_send.call_args.args[1]["attachments"][0]
assert att["contentType"] == "application/octet-stream"
assert att["contentUrl"].startswith("data:application/octet-stream;base64,")

@pytest.mark.asyncio
async def test_file_with_unresolvable_data_is_skipped(self):
"""A FileUpload whose data is not bytes is skipped with a debug log.

Mirrors upstream's ``throwOnUnsupported: false`` followed by
``if (!buffer) continue``. (The Python ``FileUpload`` has no
``fetch_data`` field — it carries only inline ``data`` bytes — so the
lazy-fetch case from the upstream interface collapses to this
skip-unresolvable-bytes branch.)
"""
from chat_sdk.types import FileUpload, PostableMarkdown

logger = _make_logger()
adapter = _make_adapter(app_id="test-app-id", logger=logger)
adapter._teams_send = AsyncMock(return_value={"id": "sent-4", "type": "message"})

# data is a str, not bytes -> to_buffer returns None -> file skipped
bad = FileUpload(data="not-bytes", filename="bad.txt", mime_type="text/plain") # type: ignore[arg-type]
message = PostableMarkdown(markdown="text only", files=[bad])
await adapter.post_message(self._thread_id(adapter), message)

payload = adapter._teams_send.call_args.args[1]
# no attachments key added when every file was skipped
assert "attachments" not in payload
# assert the SPECIFIC skip log fired — not just that some debug log happened
# (post_message emits an unconditional "send (message)" debug, so a bare
# logger.debug.called check would pass even if the skip branch logged nothing).
skip_logged = any(
call.args and "skipping file with unsupported data" in str(call.args[0])
for call in logger.debug.call_args_list
)
assert skip_logged, "a skipped file must emit the 'unsupported data' debug log"

@pytest.mark.asyncio
async def test_multiple_files_attached_in_order(self):
"""N files -> N attachments, in input order. Closes the gap where
``return attachments[:1]`` (drop all but first) or a reorder would
otherwise merge green — both directly defeat multi-artifact parity.
"""
from chat_sdk.types import FileUpload, PostableMarkdown

adapter = _make_adapter(app_id="test-app-id", logger=_make_logger())
adapter._teams_send = AsyncMock(return_value={"id": "m", "type": "message"})

message = PostableMarkdown(
markdown="three files",
files=[
FileUpload(data=b"aaa", filename="a.csv", mime_type="text/csv"),
FileUpload(data=b"\x89PNG", filename="b.png", mime_type="image/png"),
FileUpload(data=b"%PDF", filename="c.pdf", mime_type="application/pdf"),
],
)
await adapter.post_message(self._thread_id(adapter), message)

attachments = adapter._teams_send.call_args.args[1]["attachments"]
assert [a["name"] for a in attachments] == ["a.csv", "b.png", "c.pdf"]
assert [a["contentType"] for a in attachments] == ["text/csv", "image/png", "application/pdf"]
assert all(a["contentUrl"].startswith("data:") for a in attachments)

@pytest.mark.asyncio
async def test_partial_skip_preserves_surviving_files_in_order(self):
"""A good/bad/good batch drops only the unresolvable file; survivors keep
input order. No single-file test covers partial-skip-with-survivors.
"""
from chat_sdk.types import FileUpload, PostableMarkdown

adapter = _make_adapter(app_id="test-app-id", logger=_make_logger())
adapter._teams_send = AsyncMock(return_value={"id": "m", "type": "message"})

message = PostableMarkdown(
markdown="good bad good",
files=[
FileUpload(data=b"first", filename="first.csv", mime_type="text/csv"),
FileUpload(data="not-bytes", filename="bad.bin", mime_type="application/octet-stream"), # type: ignore[arg-type]
FileUpload(data=b"third", filename="third.csv", mime_type="text/csv"),
],
)
await adapter.post_message(self._thread_id(adapter), message)

attachments = adapter._teams_send.call_args.args[1]["attachments"]
assert [a["name"] for a in attachments] == ["first.csv", "third.csv"]


class TestDeleteMessage:
@pytest.mark.asyncio
async def test_deletes_without_error(self):
Expand Down
Loading