From c3bdb2d739769f6b0280bad24b388e2ffeb42005 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 10 Dec 2025 18:13:08 -0800 Subject: [PATCH 1/4] feat: accept chunks as arguments to chat.{start,append,stop}Stream methods --- slack_sdk/models/messages/chunk.py | 157 +++++++++++++++++++++++++++++ slack_sdk/web/async_client.py | 11 +- slack_sdk/web/client.py | 11 +- slack_sdk/web/internal_utils.py | 14 ++- slack_sdk/web/legacy_client.py | 11 +- 5 files changed, 198 insertions(+), 6 deletions(-) create mode 100644 slack_sdk/models/messages/chunk.py diff --git a/slack_sdk/models/messages/chunk.py b/slack_sdk/models/messages/chunk.py new file mode 100644 index 000000000..e8e988265 --- /dev/null +++ b/slack_sdk/models/messages/chunk.py @@ -0,0 +1,157 @@ +import logging +from typing import Any, Dict, Literal, Optional, Sequence, Set, Union + +from slack_sdk.errors import SlackObjectFormationError +from slack_sdk.models import show_unknown_key_warning +from slack_sdk.models.basic_objects import JsonObject + +LOGGER = logging.getLogger(__name__) + + +class Chunk(JsonObject): + """ + Chunk for streaming messages. + + https://docs.slack.dev/messaging/sending-and-scheduling-messages#text-streaming + """ + + attributes = {"type"} + logger = logging.getLogger(__name__) + + def __init__( + self, + *, + type: Optional[str] = None, + ): + self.type = type + + @classmethod + def parse(cls, chunk: Union[Dict, "Chunk"]) -> Optional["Chunk"]: + if chunk is None: + return None + elif isinstance(chunk, Chunk): + return chunk + else: + if "type" in chunk: + type = chunk["type"] + if type == MarkdownTextChunk.type: + return MarkdownTextChunk(**chunk) + elif type == TaskUpdateChunk.type: + return TaskUpdateChunk(**chunk) + else: + cls.logger.warning(f"Unknown chunk detected and skipped ({chunk})") + return None + else: + cls.logger.warning(f"Unknown chunk detected and skipped ({chunk})") + return None + + +class MarkdownTextChunk(Chunk): + type = "markdown_text" + + @property + def attributes(self) -> Set[str]: # type: ignore[override] + return super().attributes.union({"text"}) + + def __init__( + self, + *, + text: str, + **others: Dict, + ): + """Used for streaming text content with markdown formatting support. + + https://docs.slack.dev/messaging/sending-and-scheduling-messages#text-streaming + """ + super().__init__(type=self.type) + show_unknown_key_warning(self, others) + + self.text = text + + +class URLSource(JsonObject): + type = "url" + + @property + def attributes(self) -> Set[str]: # type: ignore[override] + return super().attributes.union( + { + "url", + "text", + "icon_url", + } + ) + + def __init__( + self, + *, + url: str, + text: str, + icon_url: Optional[str] = None, + **others: Dict, + ): + show_unknown_key_warning(self, others) + self._url = url + self._text = text + self._icon_url = icon_url + + def to_dict(self) -> Dict[str, Any]: + self.validate_json() + json: Dict[str, Union[str, Dict]] = { + "type": self.type, + "url": self._url, + "text": self._text, + } + if self._icon_url: + json["icon_url"] = self._icon_url + return json + + +class TaskUpdateChunk(Chunk): + type = "task_update" + + @property + def attributes(self) -> Set[str]: # type: ignore[override] + return super().attributes.union( + { + "id", + "title", + "status", + "details", + "output", + "sources", + } + ) + + def __init__( + self, + *, + id: str, + title: str, + status: Literal["pending", "in_progress", "complete", "error"], + details: Optional[str] = None, + output: Optional[str] = None, + sources: Optional[Sequence[Union[Dict, URLSource]]] = None, + **others: Dict, + ): + """Used for displaying tool execution progress in a timeline-style UI. + + https://docs.slack.dev/messaging/sending-and-scheduling-messages#text-streaming + """ + super().__init__(type=self.type) + show_unknown_key_warning(self, others) + + self.id = id + self.title = title + self.status = status + self.details = details + self.output = output + if sources is not None: + self.sources = [] + for src in sources: + if isinstance(src, Dict): + self.sources.append(src) + elif isinstance(src, URLSource): + self.sources.append(src.to_dict()) + else: + raise SlackObjectFormationError(f"Unsupported type for source in task update chunk: {type(src)}") diff --git a/slack_sdk/web/async_client.py b/slack_sdk/web/async_client.py index ca163da98..0a9f702b9 100644 --- a/slack_sdk/web/async_client.py +++ b/slack_sdk/web/async_client.py @@ -17,12 +17,13 @@ from typing import Any, Dict, List, Optional, Sequence, Union import slack_sdk.errors as e +from slack_sdk.models.messages.chunk import Chunk from slack_sdk.models.views import View from slack_sdk.web.async_chat_stream import AsyncChatStream from ..models.attachments import Attachment from ..models.blocks import Block, RichTextBlock -from ..models.metadata import Metadata, EntityMetadata, EventAndEntityMetadata +from ..models.metadata import EntityMetadata, EventAndEntityMetadata, Metadata from .async_base_client import AsyncBaseClient, AsyncSlackResponse from .internal_utils import ( _parse_web_class_objects, @@ -2631,6 +2632,7 @@ async def chat_appendStream( channel: str, ts: str, markdown_text: str, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> AsyncSlackResponse: """Appends text to an existing streaming conversation. @@ -2641,8 +2643,10 @@ async def chat_appendStream( "channel": channel, "ts": ts, "markdown_text": markdown_text, + "chunks": chunks, } ) + _parse_web_class_objects(kwargs) kwargs = _remove_none_values(kwargs) return await self.api_call("chat.appendStream", json=kwargs) @@ -2884,6 +2888,7 @@ async def chat_startStream( markdown_text: Optional[str] = None, recipient_team_id: Optional[str] = None, recipient_user_id: Optional[str] = None, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> AsyncSlackResponse: """Starts a new streaming conversation. @@ -2896,8 +2901,10 @@ async def chat_startStream( "markdown_text": markdown_text, "recipient_team_id": recipient_team_id, "recipient_user_id": recipient_user_id, + "chunks": chunks, } ) + _parse_web_class_objects(kwargs) kwargs = _remove_none_values(kwargs) return await self.api_call("chat.startStream", json=kwargs) @@ -2909,6 +2916,7 @@ async def chat_stopStream( markdown_text: Optional[str] = None, blocks: Optional[Union[str, Sequence[Union[Dict, Block]]]] = None, metadata: Optional[Union[Dict, Metadata]] = None, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> AsyncSlackResponse: """Stops a streaming conversation. @@ -2921,6 +2929,7 @@ async def chat_stopStream( "markdown_text": markdown_text, "blocks": blocks, "metadata": metadata, + "chunks": chunks, } ) _parse_web_class_objects(kwargs) diff --git a/slack_sdk/web/client.py b/slack_sdk/web/client.py index dfa771832..1a70681a4 100644 --- a/slack_sdk/web/client.py +++ b/slack_sdk/web/client.py @@ -7,12 +7,13 @@ from typing import Any, Dict, List, Optional, Sequence, Union import slack_sdk.errors as e +from slack_sdk.models.messages.chunk import Chunk from slack_sdk.models.views import View from slack_sdk.web.chat_stream import ChatStream from ..models.attachments import Attachment from ..models.blocks import Block, RichTextBlock -from ..models.metadata import Metadata, EntityMetadata, EventAndEntityMetadata +from ..models.metadata import EntityMetadata, EventAndEntityMetadata, Metadata from .base_client import BaseClient, SlackResponse from .internal_utils import ( _parse_web_class_objects, @@ -2621,6 +2622,7 @@ def chat_appendStream( channel: str, ts: str, markdown_text: str, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> SlackResponse: """Appends text to an existing streaming conversation. @@ -2631,8 +2633,10 @@ def chat_appendStream( "channel": channel, "ts": ts, "markdown_text": markdown_text, + "chunks": chunks, } ) + _parse_web_class_objects(kwargs) kwargs = _remove_none_values(kwargs) return self.api_call("chat.appendStream", json=kwargs) @@ -2874,6 +2878,7 @@ def chat_startStream( markdown_text: Optional[str] = None, recipient_team_id: Optional[str] = None, recipient_user_id: Optional[str] = None, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> SlackResponse: """Starts a new streaming conversation. @@ -2886,8 +2891,10 @@ def chat_startStream( "markdown_text": markdown_text, "recipient_team_id": recipient_team_id, "recipient_user_id": recipient_user_id, + "chunks": chunks, } ) + _parse_web_class_objects(kwargs) kwargs = _remove_none_values(kwargs) return self.api_call("chat.startStream", json=kwargs) @@ -2899,6 +2906,7 @@ def chat_stopStream( markdown_text: Optional[str] = None, blocks: Optional[Union[str, Sequence[Union[Dict, Block]]]] = None, metadata: Optional[Union[Dict, Metadata]] = None, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> SlackResponse: """Stops a streaming conversation. @@ -2911,6 +2919,7 @@ def chat_stopStream( "markdown_text": markdown_text, "blocks": blocks, "metadata": metadata, + "chunks": chunks, } ) _parse_web_class_objects(kwargs) diff --git a/slack_sdk/web/internal_utils.py b/slack_sdk/web/internal_utils.py index 87139559c..ad23f87f8 100644 --- a/slack_sdk/web/internal_utils.py +++ b/slack_sdk/web/internal_utils.py @@ -11,13 +11,14 @@ from ssl import SSLContext from typing import Any, Dict, Optional, Sequence, Union from urllib.parse import urljoin -from urllib.request import OpenerDirector, ProxyHandler, HTTPSHandler, Request, urlopen +from urllib.request import HTTPSHandler, OpenerDirector, ProxyHandler, Request, urlopen from slack_sdk import version from slack_sdk.errors import SlackRequestError from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block -from slack_sdk.models.metadata import Metadata, EventAndEntityMetadata, EntityMetadata +from slack_sdk.models.messages.chunk import Chunk +from slack_sdk.models.metadata import EntityMetadata, EventAndEntityMetadata, Metadata def convert_bool_to_0_or_1(params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]: @@ -187,11 +188,13 @@ def _build_req_args( def _parse_web_class_objects(kwargs) -> None: - def to_dict(obj: Union[Dict, Block, Attachment, Metadata, EventAndEntityMetadata, EntityMetadata]): + def to_dict(obj: Union[Dict, Block, Attachment, Chunk, Metadata, EventAndEntityMetadata, EntityMetadata]): if isinstance(obj, Block): return obj.to_dict() if isinstance(obj, Attachment): return obj.to_dict() + if isinstance(obj, Chunk): + return obj.to_dict() if isinstance(obj, Metadata): return obj.to_dict() if isinstance(obj, EventAndEntityMetadata): @@ -211,6 +214,11 @@ def to_dict(obj: Union[Dict, Block, Attachment, Metadata, EventAndEntityMetadata dict_attachments = [to_dict(a) for a in attachments] kwargs.update({"attachments": dict_attachments}) + chunks = kwargs.get("chunks", None) + if chunks is not None and isinstance(chunks, Sequence) and (not isinstance(chunks, str)): + dict_chunks = [to_dict(c) for c in chunks] + kwargs.update({"chunks": dict_chunks}) + metadata = kwargs.get("metadata", None) if metadata is not None and ( isinstance(metadata, Metadata) diff --git a/slack_sdk/web/legacy_client.py b/slack_sdk/web/legacy_client.py index df2bcc370..f11bbc495 100644 --- a/slack_sdk/web/legacy_client.py +++ b/slack_sdk/web/legacy_client.py @@ -19,11 +19,12 @@ from typing import Any, Dict, List, Optional, Sequence, Union import slack_sdk.errors as e +from slack_sdk.models.messages.chunk import Chunk from slack_sdk.models.views import View from ..models.attachments import Attachment from ..models.blocks import Block, RichTextBlock -from ..models.metadata import Metadata, EntityMetadata, EventAndEntityMetadata +from ..models.metadata import EntityMetadata, EventAndEntityMetadata, Metadata from .legacy_base_client import LegacyBaseClient, SlackResponse from .internal_utils import ( _parse_web_class_objects, @@ -2632,6 +2633,7 @@ def chat_appendStream( channel: str, ts: str, markdown_text: str, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> Union[Future, SlackResponse]: """Appends text to an existing streaming conversation. @@ -2642,8 +2644,10 @@ def chat_appendStream( "channel": channel, "ts": ts, "markdown_text": markdown_text, + "chunks": chunks, } ) + _parse_web_class_objects(kwargs) kwargs = _remove_none_values(kwargs) return self.api_call("chat.appendStream", json=kwargs) @@ -2885,6 +2889,7 @@ def chat_startStream( markdown_text: Optional[str] = None, recipient_team_id: Optional[str] = None, recipient_user_id: Optional[str] = None, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> Union[Future, SlackResponse]: """Starts a new streaming conversation. @@ -2897,8 +2902,10 @@ def chat_startStream( "markdown_text": markdown_text, "recipient_team_id": recipient_team_id, "recipient_user_id": recipient_user_id, + "chunks": chunks, } ) + _parse_web_class_objects(kwargs) kwargs = _remove_none_values(kwargs) return self.api_call("chat.startStream", json=kwargs) @@ -2910,6 +2917,7 @@ def chat_stopStream( markdown_text: Optional[str] = None, blocks: Optional[Union[str, Sequence[Union[Dict, Block]]]] = None, metadata: Optional[Union[Dict, Metadata]] = None, + chunks: Optional[Sequence[Union[Dict, Chunk]]] = None, **kwargs, ) -> Union[Future, SlackResponse]: """Stops a streaming conversation. @@ -2922,6 +2930,7 @@ def chat_stopStream( "markdown_text": markdown_text, "blocks": blocks, "metadata": metadata, + "chunks": chunks, } ) _parse_web_class_objects(kwargs) From 8c56e22dc96d76f6825600d418e47189666d3ea9 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Mon, 15 Dec 2025 22:32:58 -0800 Subject: [PATCH 2/4] fix: remove unsupported and unused identifiers for full support --- slack_sdk/models/messages/chunk.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/slack_sdk/models/messages/chunk.py b/slack_sdk/models/messages/chunk.py index e8e988265..05ddff2c9 100644 --- a/slack_sdk/models/messages/chunk.py +++ b/slack_sdk/models/messages/chunk.py @@ -1,12 +1,10 @@ import logging -from typing import Any, Dict, Literal, Optional, Sequence, Set, Union +from typing import Any, Dict, Optional, Sequence, Set, Union from slack_sdk.errors import SlackObjectFormationError from slack_sdk.models import show_unknown_key_warning from slack_sdk.models.basic_objects import JsonObject -LOGGER = logging.getLogger(__name__) - class Chunk(JsonObject): """ @@ -128,7 +126,7 @@ def __init__( *, id: str, title: str, - status: Literal["pending", "in_progress", "complete", "error"], + status: str, # "pending", "in_progress", "complete", "error" details: Optional[str] = None, output: Optional[str] = None, sources: Optional[Sequence[Union[Dict, URLSource]]] = None, From 783ada59bb3b0d87c1432a16409824e678f2c2cc Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 17 Dec 2025 14:10:07 -0800 Subject: [PATCH 3/4] style: remove mypy extra ignore comment for overriden attributes --- slack_sdk/models/messages/chunk.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/slack_sdk/models/messages/chunk.py b/slack_sdk/models/messages/chunk.py index 05ddff2c9..837714af0 100644 --- a/slack_sdk/models/messages/chunk.py +++ b/slack_sdk/models/messages/chunk.py @@ -71,7 +71,7 @@ class URLSource(JsonObject): type = "url" @property - def attributes(self) -> Set[str]: # type: ignore[override] + def attributes(self) -> Set[str]: return super().attributes.union( { "url", From 1fb735585b6cc3250c758d69b2e3e434983dcfd0 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Wed, 17 Dec 2025 14:35:46 -0800 Subject: [PATCH 4/4] test: confirm chunks parse as expected json values --- tests/slack_sdk/models/test_chunks.py | 72 ++++++++++++++++++++++ tests/slack_sdk/web/test_internal_utils.py | 31 ++++++++-- 2 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 tests/slack_sdk/models/test_chunks.py diff --git a/tests/slack_sdk/models/test_chunks.py b/tests/slack_sdk/models/test_chunks.py new file mode 100644 index 000000000..1b8b58c96 --- /dev/null +++ b/tests/slack_sdk/models/test_chunks.py @@ -0,0 +1,72 @@ +import unittest + +from slack_sdk.models.messages.chunk import MarkdownTextChunk, TaskUpdateChunk, URLSource + + +class MarkdownTextChunkTests(unittest.TestCase): + def test_json(self): + self.assertDictEqual( + MarkdownTextChunk(text="greetings!").to_dict(), + { + "type": "markdown_text", + "text": "greetings!", + }, + ) + + +class TaskUpdateChunkTests(unittest.TestCase): + def test_json(self): + self.assertDictEqual( + TaskUpdateChunk(id="001", title="Waiting...", status="pending").to_dict(), + { + "type": "task_update", + "id": "001", + "title": "Waiting...", + "status": "pending", + }, + ) + self.assertDictEqual( + TaskUpdateChunk( + id="002", + title="Wondering...", + status="in_progress", + details="- Gathering information...", + ).to_dict(), + { + "type": "task_update", + "id": "002", + "title": "Wondering...", + "status": "in_progress", + "details": "- Gathering information...", + }, + ) + self.assertDictEqual( + TaskUpdateChunk( + id="003", + title="Answering...", + status="complete", + output="Found a solution", + sources=[ + URLSource( + text="The Free Encyclopedia", + url="https://wikipedia.org", + icon_url="https://example.com/globe.png", + ), + ], + ).to_dict(), + { + "type": "task_update", + "id": "003", + "title": "Answering...", + "status": "complete", + "output": "Found a solution", + "sources": [ + { + "type": "url", + "text": "The Free Encyclopedia", + "url": "https://wikipedia.org", + "icon_url": "https://example.com/globe.png", + }, + ], + }, + ) diff --git a/tests/slack_sdk/web/test_internal_utils.py b/tests/slack_sdk/web/test_internal_utils.py index ac7704b30..fc6574aab 100644 --- a/tests/slack_sdk/web/test_internal_utils.py +++ b/tests/slack_sdk/web/test_internal_utils.py @@ -2,18 +2,18 @@ import unittest from io import BytesIO from pathlib import Path -from typing import Dict, Sequence, Union +from typing import Dict -import pytest from slack_sdk.models.attachments import Attachment from slack_sdk.models.blocks import Block, DividerBlock +from slack_sdk.models.messages.chunk import MarkdownTextChunk, TaskUpdateChunk from slack_sdk.web.internal_utils import ( _build_unexpected_body_error_message, + _get_url, + _next_cursor_is_present, _parse_web_class_objects, _to_v2_file_upload_item, - _next_cursor_is_present, - _get_url, ) @@ -57,6 +57,20 @@ def test_can_parse_sequence_of_attachments(self): for attachment in kwargs["attachments"]: assert isinstance(attachment, Dict) + def test_can_parse_sequence_of_chunks(self): + for chunks in [ + [MarkdownTextChunk(text="fiz"), TaskUpdateChunk(id="001", title="baz", status="complete")], # list + ( + MarkdownTextChunk(text="fiz"), + TaskUpdateChunk(id="001", title="baz", status="complete"), + ), # tuple + ]: + kwargs = {"chunks": chunks} + _parse_web_class_objects(kwargs) + assert kwargs["chunks"] + for chunks in kwargs["chunks"]: + assert isinstance(chunks, Dict) + def test_can_parse_str_blocks(self): input = json.dumps([Block(block_id="42").to_dict(), Block(block_id="24").to_dict()]) kwargs = {"blocks": input} @@ -71,6 +85,15 @@ def test_can_parse_str_attachments(self): assert isinstance(kwargs["attachments"], str) assert input == kwargs["attachments"] + def test_can_parse_str_chunks(self): + input = json.dumps( + [MarkdownTextChunk(text="fiz").to_dict(), TaskUpdateChunk(id="001", title="baz", status="complete").to_dict()] + ) + kwargs = {"chunks": input} + _parse_web_class_objects(kwargs) + assert isinstance(kwargs["chunks"], str) + assert input == kwargs["chunks"] + def test_can_parse_user_auth_blocks(self): kwargs = { "channel": "C12345",