Skip to content

Commit b031148

Browse files
authored
feat: add task_card and plan blocks (#1819)
1 parent f4c0182 commit b031148

File tree

6 files changed

+253
-52
lines changed

6 files changed

+253
-52
lines changed

slack_sdk/models/blocks/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
StaticSelectElement,
5656
TimePickerElement,
5757
UrlInputElement,
58+
UrlSourceElement,
5859
UserMultiSelectElement,
5960
UserSelectElement,
6061
)
@@ -70,9 +71,11 @@
7071
ImageBlock,
7172
InputBlock,
7273
MarkdownBlock,
74+
PlanBlock,
7375
RichTextBlock,
7476
SectionBlock,
7577
TableBlock,
78+
TaskCardBlock,
7679
VideoBlock,
7780
)
7881

@@ -111,6 +114,7 @@
111114
"PlainTextInputElement",
112115
"EmailInputElement",
113116
"UrlInputElement",
117+
"UrlSourceElement",
114118
"NumberInputElement",
115119
"RadioButtonsElement",
116120
"SelectElement",
@@ -135,8 +139,10 @@
135139
"ImageBlock",
136140
"InputBlock",
137141
"MarkdownBlock",
142+
"PlanBlock",
138143
"SectionBlock",
139144
"TableBlock",
145+
"TaskCardBlock",
140146
"VideoBlock",
141147
"RichTextBlock",
142148
]

slack_sdk/models/blocks/block_elements.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1654,6 +1654,48 @@ def __init__(
16541654
self.dispatch_action_config = dispatch_action_config
16551655

16561656

1657+
# -------------------------------------------------
1658+
# Url Source Element
1659+
# -------------------------------------------------
1660+
1661+
1662+
class UrlSourceElement(BlockElement):
1663+
type = "url"
1664+
1665+
@property
1666+
def attributes(self) -> Set[str]: # type: ignore[override]
1667+
return super().attributes.union(
1668+
{
1669+
"url",
1670+
"text",
1671+
"icon_url",
1672+
}
1673+
)
1674+
1675+
def __init__(
1676+
self,
1677+
*,
1678+
url: str,
1679+
text: str,
1680+
icon_url: Optional[str] = None,
1681+
**others: Dict,
1682+
):
1683+
"""
1684+
A URL source element to reference in a task card block.
1685+
https://docs.slack.dev/reference/block-kit/block-elements/url-source-element
1686+
1687+
Args:
1688+
url (required): The URL type source.
1689+
text (required): Display text for the URL.
1690+
icon_url: Optional icon URL to display with the source.
1691+
"""
1692+
super().__init__(type=self.type)
1693+
show_unknown_key_warning(self, others)
1694+
self.url = url
1695+
self.text = text
1696+
self.icon_url = icon_url
1697+
1698+
16571699
# -------------------------------------------------
16581700
# Number Input Element
16591701
# -------------------------------------------------

slack_sdk/models/blocks/blocks.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
InputInteractiveElement,
1717
InteractiveElement,
1818
RichTextElement,
19+
UrlSourceElement,
1920
)
2021

2122
# -------------------------------------------------
@@ -97,6 +98,10 @@ def parse(cls, block: Union[dict, "Block"]) -> Optional["Block"]:
9798
return RichTextBlock(**block)
9899
elif type == TableBlock.type:
99100
return TableBlock(**block)
101+
elif type == TaskCardBlock.type:
102+
return TaskCardBlock(**block)
103+
elif type == PlanBlock.type:
104+
return PlanBlock(**block)
100105
else:
101106
cls.logger.warning(f"Unknown block detected and skipped ({block})")
102107
return None
@@ -777,3 +782,104 @@ def __init__(
777782
@JsonValidator("rows attribute must be specified")
778783
def _validate_rows(self):
779784
return self.rows is not None and len(self.rows) > 0
785+
786+
787+
class TaskCardBlock(Block):
788+
type = "task_card"
789+
790+
@property
791+
def attributes(self) -> Set[str]: # type: ignore[override]
792+
return super().attributes.union(
793+
{
794+
"task_id",
795+
"title",
796+
"details",
797+
"output",
798+
"sources",
799+
"status",
800+
}
801+
)
802+
803+
def __init__(
804+
self,
805+
*,
806+
task_id: str,
807+
title: str,
808+
details: Optional[Union[RichTextBlock, dict]] = None,
809+
output: Optional[Union[RichTextBlock, dict]] = None,
810+
sources: Optional[Sequence[Union[UrlSourceElement, dict]]] = None,
811+
status: str, # pending, in_progress, complete, error
812+
block_id: Optional[str] = None,
813+
**others: dict,
814+
):
815+
"""A discrete action or tool call.
816+
https://docs.slack.dev/reference/block-kit/blocks/task-card-block/
817+
818+
Args:
819+
block_id: A string acting as a unique identifier for a block. If not specified, one will be generated.
820+
Maximum length for this field is 255 characters.
821+
block_id should be unique for each message and each iteration of a message.
822+
If a message is updated, use a new block_id.
823+
task_id (required): ID for the task
824+
title (required): Title of the task in plain text
825+
details: Details of the task in the form of a single "rich_text" entity.
826+
output: Output of the task in the form of a single "rich_text" entity.
827+
sources: List of sources used to generate a response
828+
status: The state of a task. Either "pending" or "in_progress" or "complete" or "error".
829+
"""
830+
super().__init__(type=self.type, block_id=block_id)
831+
show_unknown_key_warning(self, others)
832+
833+
self.task_id = task_id
834+
self.title = title
835+
self.details = details
836+
self.output = output
837+
self.sources = sources
838+
self.status = status
839+
840+
@JsonValidator("status must be an expected value (pending, in_progress, complete, or error)")
841+
def _validate_rows(self):
842+
return self.status in ["pending", "in_progress", "complete", "error"]
843+
844+
845+
class PlanBlock(Block):
846+
type = "plan"
847+
848+
@property
849+
def attributes(self) -> Set[str]: # type: ignore[override]
850+
return super().attributes.union(
851+
{
852+
"plan_id",
853+
"title",
854+
"tasks",
855+
}
856+
)
857+
858+
def __init__(
859+
self,
860+
*,
861+
plan_id: str,
862+
title: str,
863+
tasks: Optional[Sequence[Union[Dict, TaskCardBlock]]] = None,
864+
block_id: Optional[str] = None,
865+
**others: dict,
866+
):
867+
"""A collection of related tasks.
868+
https://docs.slack.dev/reference/block-kit/blocks/plan-block/
869+
870+
Args:
871+
block_id: A string acting as a unique identifier for a block. If not specified, one will be generated.
872+
Maximum length for this field is 255 characters.
873+
block_id should be unique for each message and each iteration of a message.
874+
If a message is updated, use a new block_id.
875+
plan_id (required): ID for the plan (May be removed / made optional, feel free to pass in a random UUID
876+
for now)
877+
title (required): Title of the plan in plain text
878+
tasks: Details of the task in the form of a single "rich_text" entity.
879+
"""
880+
super().__init__(type=self.type, block_id=block_id)
881+
show_unknown_key_warning(self, others)
882+
883+
self.plan_id = plan_id
884+
self.title = title
885+
self.tasks = tasks

slack_sdk/models/messages/chunk.py

Lines changed: 4 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import logging
2-
from typing import Any, Dict, Optional, Sequence, Set, Union
2+
from typing import Dict, Optional, Sequence, Set, Union
33

4-
from slack_sdk.errors import SlackObjectFormationError
54
from slack_sdk.models import show_unknown_key_warning
65
from slack_sdk.models.basic_objects import JsonObject
6+
from slack_sdk.models.blocks.block_elements import UrlSourceElement
77

88

99
class Chunk(JsonObject):
@@ -67,44 +67,6 @@ def __init__(
6767
self.text = text
6868

6969

70-
class URLSource(JsonObject):
71-
type = "url"
72-
73-
@property
74-
def attributes(self) -> Set[str]:
75-
return super().attributes.union(
76-
{
77-
"url",
78-
"text",
79-
"icon_url",
80-
}
81-
)
82-
83-
def __init__(
84-
self,
85-
*,
86-
url: str,
87-
text: str,
88-
icon_url: Optional[str] = None,
89-
**others: Dict,
90-
):
91-
show_unknown_key_warning(self, others)
92-
self._url = url
93-
self._text = text
94-
self._icon_url = icon_url
95-
96-
def to_dict(self) -> Dict[str, Any]:
97-
self.validate_json()
98-
json: Dict[str, Union[str, Dict]] = {
99-
"type": self.type,
100-
"url": self._url,
101-
"text": self._text,
102-
}
103-
if self._icon_url:
104-
json["icon_url"] = self._icon_url
105-
return json
106-
107-
10870
class TaskUpdateChunk(Chunk):
10971
type = "task_update"
11072

@@ -129,7 +91,7 @@ def __init__(
12991
status: str, # "pending", "in_progress", "complete", "error"
13092
details: Optional[str] = None,
13193
output: Optional[str] = None,
132-
sources: Optional[Sequence[Union[Dict, URLSource]]] = None,
94+
sources: Optional[Sequence[Union[Dict, UrlSourceElement]]] = None,
13395
**others: Dict,
13496
):
13597
"""Used for displaying tool execution progress in a timeline-style UI.
@@ -144,12 +106,4 @@ def __init__(
144106
self.status = status
145107
self.details = details
146108
self.output = output
147-
if sources is not None:
148-
self.sources = []
149-
for src in sources:
150-
if isinstance(src, Dict):
151-
self.sources.append(src)
152-
elif isinstance(src, URLSource):
153-
self.sources.append(src.to_dict())
154-
else:
155-
raise SlackObjectFormationError(f"Unsupported type for source in task update chunk: {type(src)}")
109+
self.sources = sources

tests/slack_sdk/models/test_blocks.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
Option,
2222
OverflowMenuElement,
2323
PlainTextObject,
24+
PlanBlock,
2425
RawTextObject,
2526
RichTextBlock,
2627
RichTextElementParts,
@@ -31,6 +32,7 @@
3132
SectionBlock,
3233
StaticSelectElement,
3334
TableBlock,
35+
TaskCardBlock,
3436
VideoBlock,
3537
)
3638
from slack_sdk.models.blocks.basic_components import FeedbackButtonObject, SlackFile
@@ -890,6 +892,87 @@ def test_text_length_12001(self):
890892
MarkdownBlock(**input).validate_json()
891893

892894

895+
# ----------------------------------------------
896+
# Plan
897+
# ----------------------------------------------
898+
899+
900+
class PlanBlockTests(unittest.TestCase):
901+
def test_document(self):
902+
input = {
903+
"type": "plan",
904+
"plan_id": "plan_1",
905+
"title": "Thinking completed",
906+
"tasks": [
907+
{
908+
"task_id": "call_001",
909+
"title": "Fetched user profile information",
910+
"status": "in_progress",
911+
"details": {
912+
"type": "rich_text",
913+
"elements": [
914+
{"type": "rich_text_section", "elements": [{"type": "text", "text": "Searched database..."}]}
915+
],
916+
},
917+
"output": {
918+
"type": "rich_text",
919+
"elements": [
920+
{"type": "rich_text_section", "elements": [{"type": "text", "text": "Profile data loaded"}]}
921+
],
922+
},
923+
},
924+
{
925+
"task_id": "call_002",
926+
"title": "Checked user permissions",
927+
"status": "pending",
928+
},
929+
{
930+
"task_id": "call_003",
931+
"title": "Generated comprehensive user report",
932+
"status": "complete",
933+
"output": {
934+
"type": "rich_text",
935+
"elements": [
936+
{"type": "rich_text_section", "elements": [{"type": "text", "text": "15 data points compiled"}]}
937+
],
938+
},
939+
},
940+
],
941+
}
942+
self.assertDictEqual(input, PlanBlock(**input).to_dict())
943+
self.assertDictEqual(input, Block.parse(input).to_dict())
944+
945+
946+
# ----------------------------------------------
947+
# Task card
948+
# ----------------------------------------------
949+
950+
951+
class TaskCardBlockTests(unittest.TestCase):
952+
def test_document(self):
953+
input = {
954+
"type": "task_card",
955+
"task_id": "task_1",
956+
"title": "Fetching weather data",
957+
"status": "pending",
958+
"output": {
959+
"type": "rich_text",
960+
"elements": [
961+
{
962+
"type": "rich_text_section",
963+
"elements": [{"type": "text", "text": "Found weather data for Chicago from 2 sources"}],
964+
}
965+
],
966+
},
967+
"sources": [
968+
{"type": "url", "url": "https://weather.com/", "text": "weather.com"},
969+
{"type": "url", "url": "https://www.accuweather.com/", "text": "accuweather.com"},
970+
],
971+
}
972+
self.assertDictEqual(input, TaskCardBlock(**input).to_dict())
973+
self.assertDictEqual(input, Block.parse(input).to_dict())
974+
975+
893976
# ----------------------------------------------
894977
# Video
895978
# ----------------------------------------------

0 commit comments

Comments
 (0)