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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## 0.4.29a2 (2026-05-28)

Python-only fix. No upstream version change.

### Fixes

- **Slack now surfaces `files_upload_v2` confirmation through `post()`** — `SlackAdapter._upload_files` already computed the list of Slack-confirmed file IDs but `post_message` discarded the return, and `ThreadImpl._create_sent_message` hardcoded `raw=None`, so the confirmation never reached `SentMessage.raw`. Slack was the only file-capable adapter to drop this; discord/telegram upload inline and expose the platform response naturally. `post_message` now augments `RawMessage.raw` with `"uploaded_file_ids"` on every return path that can carry files (file-only, card, table, text), and `ThreadImpl._create_sent_message` accepts and propagates the adapter's `raw` into `SentMessage.raw`. `None` means no upload occurred; an empty list signals Slack confirmed zero attachments. The `raw` payload is augmented, not replaced, so existing consumers are unaffected. Unblocks chinchill gating UX on actual delivery success. An upstream `vercel/chat` issue is filed in parallel for convergence.

### Test quality

- Added 4 tests: three in `tests/test_slack_api.py` (file-only, text+files, and text-only-no-augment paths) and one end-to-end `tests/test_thread_faithful.py` test verifying `post()` propagates `RawMessage.raw` to `SentMessage.raw`.

## 0.4.29a1 (2026-05-28)

Alpha sync starter for upstream `4.29.0` (`vercel/chat` release commit
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "chat-sdk"
version = "0.4.29a1"
version = "0.4.29a2"
description = "Multi-platform async chat SDK for Python — port of Vercel Chat"
keywords = [
"chat",
Expand Down
42 changes: 36 additions & 6 deletions src/chat_sdk/adapters/slack/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3389,10 +3389,16 @@ async def post_message(self, thread_id: str, message: AdapterPostableMessage) ->
try:
client = self._get_client()

# Check for files to upload
# Check for files to upload. ``files_upload_v2`` returns the
# Slack-confirmed file IDs; we surface them on ``RawMessage.raw``
# so consumers can gate on actual delivery (parity with
# discord/telegram, which upload inline and expose the platform
# response naturally). ``None`` means no upload happened; an empty
# list means Slack confirmed zero attachments (a real signal).
uploaded_file_ids: list[str] | None = None
files = extract_files(message)
if files:
await self._upload_files(files, channel, thread_ts or None)
uploaded_file_ids = await self._upload_files(files, channel, thread_ts or None)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Read Slack file IDs from the actual response shape

When this new path surfaces _upload_files()'s return value, real slack_sdk uploads will usually report uploaded_file_ids: []: the SDK's files_upload_v2 returns the files.completeUploadExternal response and puts uploaded files at completion["files"][i]["id"] (plus completion.data["file"] for a singleton), while _upload_files() only scans a nested uploaded["files"] list at lines 4069-4070. In production Slack uploads, consumers gating on this confirmation will see “zero attachments confirmed” even after successful uploads.

Useful? React with 👍 / 👎.

has_text = (
isinstance(message, str)
or (hasattr(message, "raw") and getattr(message, "raw", None))
Expand All @@ -3404,7 +3410,7 @@ async def post_message(self, thread_id: str, message: AdapterPostableMessage) ->
return RawMessage(
id=f"file-{int(time.time() * 1000)}",
thread_id=thread_id,
raw={"files": files},
raw=self._augment_raw_with_uploads({"files": files}, uploaded_file_ids),
)

card = extract_card(message)
Expand All @@ -3426,7 +3432,10 @@ async def post_message(self, thread_id: str, message: AdapterPostableMessage) ->
return RawMessage(
id=result.get("ts", ""),
thread_id=thread_id,
raw=result.data if hasattr(result, "data") else result,
raw=self._augment_raw_with_uploads(
result.data if hasattr(result, "data") else result,
uploaded_file_ids,
),
)

# Table blocks
Expand All @@ -3443,7 +3452,10 @@ async def post_message(self, thread_id: str, message: AdapterPostableMessage) ->
return RawMessage(
id=result.get("ts", ""),
thread_id=thread_id,
raw=result.data if hasattr(result, "data") else result,
raw=self._augment_raw_with_uploads(
result.data if hasattr(result, "data") else result,
uploaded_file_ids,
),
)

# Regular text
Expand All @@ -3462,11 +3474,29 @@ async def post_message(self, thread_id: str, message: AdapterPostableMessage) ->
return RawMessage(
id=result.get("ts", ""),
thread_id=thread_id,
raw=result.data if hasattr(result, "data") else result,
raw=self._augment_raw_with_uploads(
result.data if hasattr(result, "data") else result,
uploaded_file_ids,
),
)
except Exception as error:
self._handle_slack_error(error)

@staticmethod
def _augment_raw_with_uploads(raw: Any, uploaded_file_ids: list[str] | None) -> Any:
"""Add Slack-confirmed file IDs to a ``RawMessage.raw`` payload.

Returns ``raw`` unchanged when no upload occurred (``uploaded_file_ids``
is ``None``). Otherwise returns a NEW dict that merges the existing raw
(Slack never returns an ``uploaded_file_ids`` key, so this is additive
and non-breaking) with the confirmed IDs. An empty list is preserved —
it signals that Slack confirmed zero attachments.
"""
if uploaded_file_ids is None:
return raw
base = raw if isinstance(raw, dict) else {}
return {**base, "uploaded_file_ids": uploaded_file_ids}

async def edit_message(
self,
thread_id: str,
Expand Down
6 changes: 6 additions & 0 deletions src/chat_sdk/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -2483,7 +2483,13 @@ def __init__(self, state: StateAdapter, config: dict[str, Any] | None = None) ->

async def append(self, thread_id: str, message: Message) -> None:
key = f"msg-history:{thread_id}"
# Serialize with raw nulled out to save storage (matches
# MessageHistoryCache.append in message_history.py). Without this,
# SentMessage.raw — now populated post-#117 with platform payloads
# like Slack team_id/user_id, Discord guild IDs — would persist to
# the state adapter on every reply, inflating storage and PII surface.
data = message.to_json()
data["raw"] = None
await self._state.append_to_list(key, data, max_length=self._max_messages, ttl_ms=self._ttl_ms)

async def get_messages(self, thread_id: str, limit: int | None = None) -> list[Message]:
Expand Down
5 changes: 3 additions & 2 deletions src/chat_sdk/thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,7 @@ async def post(

postable: AdapterPostableMessage = message # type: ignore[assignment]
raw_msg = await self.adapter.post_message(self._id, postable)
result = self._create_sent_message(raw_msg.id, postable, raw_msg.thread_id)
result = self._create_sent_message(raw_msg.id, postable, raw_msg.thread_id, raw=raw_msg.raw)

if self._message_history is not None:
await self._message_history.append(self._id, _to_message(result))
Expand Down Expand Up @@ -1122,6 +1122,7 @@ def _create_sent_message(
message_id: str,
postable: AdapterPostableMessage,
thread_id_override: str | None = None,
raw: Any = None,
) -> SentMessage:
adapter = self.adapter
thread_id = thread_id_override or self._id
Expand Down Expand Up @@ -1160,7 +1161,7 @@ async def _remove_reaction(emoji: EmojiValue | str) -> None:
),
attachments=attachments,
links=[],
raw=None,
raw=raw,
_edit=_edit,
_delete=_delete,
_add_reaction=_add_reaction,
Expand Down
57 changes: 57 additions & 0 deletions tests/test_slack_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,63 @@ async def test_file_only_post_returns_file_id(self):
# chat_postMessage should not have been called
assert len(client.get_calls("chat_postMessage")) == chat_post_calls_before

@pytest.mark.asyncio
async def test_file_only_post_surfaces_confirmed_upload_ids(self):
"""File-only post exposes Slack-confirmed file IDs on ``RawMessage.raw``."""
adapter, client, _ = await _init_adapter()
client.set_response(
"files_upload_v2",
{"ok": True, "files": [{"files": [{"id": "F1"}, {"id": "F2"}]}]},
)

from chat_sdk.types import FileUpload, PostableMarkdown

msg = PostableMarkdown(
markdown="",
files=[FileUpload(data=b"hello", filename="test.txt")],
)
result = await adapter.post_message("slack:C123:1234567890.000000", msg)

assert isinstance(result.raw, dict)
assert result.raw["uploaded_file_ids"] == ["F1", "F2"]
# The original raw payload is preserved (augment, don't replace).
assert "files" in result.raw

@pytest.mark.asyncio
async def test_text_with_files_surfaces_confirmed_upload_ids(self):
"""Text + files post augments the chat_postMessage raw with confirmed IDs."""
adapter, client, _ = await _init_adapter()
client.set_response("chat_postMessage", {"ok": True, "ts": "1234567890.222222"})
client.set_response(
"files_upload_v2",
{"ok": True, "files": [{"files": [{"id": "F9"}]}]},
)

from chat_sdk.types import FileUpload, PostableMarkdown

msg = PostableMarkdown(
markdown="here is the report",
files=[FileUpload(data=b"hello", filename="report.txt")],
)
result = await adapter.post_message("slack:C123:1234567890.000000", msg)

assert result.id == "1234567890.222222"
assert isinstance(result.raw, dict)
assert result.raw["uploaded_file_ids"] == ["F9"]
# The Slack chat_postMessage response is preserved alongside the IDs.
assert result.raw["ok"] is True

@pytest.mark.asyncio
async def test_text_only_post_does_not_add_uploaded_file_ids(self):
"""Posts without files leave ``raw`` unaugmented (no ``uploaded_file_ids`` key)."""
adapter, client, _ = await _init_adapter()
client.set_response("chat_postMessage", {"ok": True, "ts": "1234567890.333333"})

result = await adapter.post_message("slack:C123:1234567890.000000", "plain text")

assert isinstance(result.raw, dict)
assert "uploaded_file_ids" not in result.raw

@pytest.mark.asyncio
async def test_file_upload_uses_channel_kwarg_not_channel_id(self):
"""Regression: slack-sdk's files_upload_v2 takes ``channel=``, not ``channel_id=``.
Expand Down
23 changes: 23 additions & 0 deletions tests/test_thread_faithful.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,29 @@ async def custom_post(thread_id: str, message: Any) -> RawMessage:
result = await thread.post("Hello")
assert result.thread_id == "slack:C123:new-thread-id"

# it("should propagate adapter RawMessage.raw onto SentMessage.raw")
@pytest.mark.asyncio
async def test_should_propagate_adapter_raw_onto_sent_message(self):
"""post() must carry the adapter's RawMessage.raw through to
SentMessage.raw so consumers can read platform confirmation data
(e.g. Slack's ``uploaded_file_ids``)."""
adapter = create_mock_adapter()
state = create_mock_state()

async def custom_post(thread_id: str, message: Any) -> RawMessage:
return RawMessage(
id="msg-3",
thread_id=thread_id,
raw={"ok": True, "uploaded_file_ids": ["F1", "F2"]},
)

adapter.post_message = custom_post # type: ignore[assignment]
thread = _make_thread(adapter, state)
result = await thread.post(PostableMarkdown(markdown="report", files=[]))

assert isinstance(result.raw, dict)
assert result.raw["uploaded_file_ids"] == ["F1", "F2"]


# ===========================================================================
# Streaming
Expand Down
Loading