Skip to content
Closed
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 0.4.27.1 (2026-05-29)

Python-only patch on the 0.4.27 line. No upstream version change; does NOT include the 0.4.29 alpha sync work.

### 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. Backported from #117 (which targets the 0.4.29 line). Unblocks chinchill gating UX on actual delivery success. Upstream convergence tracked in vercel/chat#564.
- **`chat._MessageHistoryCache` now nulls `raw` before persisting to history** — mirror of the existing null-out in `message_history.MessageHistoryCache.append`. Without this, the platform payload that the above fix populates on `SentMessage.raw` (Slack `team_id`/`user_id`, Discord `guildId`, etc.) would persist on every reply to Redis/Postgres-backed state, inflating storage and PII surface. Caught by post-merge review of #117.

### Test quality

- Added 4 tests: three in `tests/test_slack_api.py` (file-only, text+files, 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.27 (2026-05-28)

Synced to upstream `vercel/chat@4.27.0` (release commit `f55378a`, Apr 30 2026). Highlights: Slack Socket Mode + dynamic bot-token resolver, Teams native DM streaming, `chat.get_user()` across all 8 adapters, Telegram MarkdownV2 rendering, and a sweep of adapter bug fixes. Sets `UPSTREAM_PARITY = "4.27.0"`.
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.27"
version = "0.4.27.1"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

When defining a versioning scheme that embeds an upstream version (such as 0.4.27.1 for a local patch on 0.4.27), there is a potential for collision if the upstream project releases a patch version (e.g., 4.27.1). If upstream patch releases are handled by bumping to the next minor version in this SDK, please ensure this behavior is explicitly documented (for example, in the README or a dedicated versioning document) to clarify that the fourth digit/local patch slot is strictly reserved for local, Python-only fixes.

References
  1. When defining a versioning scheme that embeds an upstream version, ensure that the scheme clearly differentiates between upstream patch releases and local (Python-only) fixes to avoid ambiguity and potential collisions in version numbers. If upstream patch releases are handled by bumping to the next minor version, explicitly document this to clarify that the local patch slot is reserved for local fixes.

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 Parse Slack upload IDs from the top-level files list

When this new return value is used, successful real Slack uploads will usually surface uploaded_file_ids: []: the Python SDK's files_upload_v2 returns the files.completeUploadExternal payload with top-level entries like {"files": [{"id": ...}]}, but _upload_files still only collects IDs from a nested uploaded["files"] list. As a result, consumers gating on SentMessage.raw["uploaded_file_ids"] get a false “zero attachments confirmed” signal even though the files were delivered; the extraction needs to handle the top-level file objects before this value is exposed.

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 @@ -2454,7 +2454,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