Skip to content

Commit 0266424

Browse files
committed
Add send method with kwargs, auto attachments conversion, more logging
1 parent 20933f2 commit 0266424

File tree

3 files changed

+375
-39
lines changed

3 files changed

+375
-39
lines changed

integration_tests/webhook/test_webhook.py

Lines changed: 156 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
SLACK_SDK_TEST_BOT_TOKEN
77
from slack import WebClient
88
from slack import WebhookClient
9+
from slack.web.classes.attachments import Attachment, AttachmentField
910
from slack.web.classes.blocks import SectionBlock, DividerBlock, ActionsBlock
1011
from slack.web.classes.elements import ButtonElement
1112
from slack.web.classes.objects import MarkdownTextObject, PlainTextObject
@@ -22,7 +23,7 @@ def tearDown(self):
2223
def test_webhook(self):
2324
url = os.environ[SLACK_SDK_TEST_INCOMING_WEBHOOK_URL]
2425
webhook = WebhookClient(url)
25-
response = webhook.send({"text": "Hello!"})
26+
response = webhook.send(text="Hello!")
2627
self.assertEqual(200, response.status_code)
2728
self.assertEqual("ok", response.body)
2829

@@ -43,12 +44,12 @@ def test_webhook(self):
4344
actual_text = history["messages"][0]["text"]
4445
self.assertEqual("Hello!", actual_text)
4546

46-
def test_with_block_kit_classes(self):
47+
def test_with_blocks(self):
4748
url = os.environ[SLACK_SDK_TEST_INCOMING_WEBHOOK_URL]
4849
webhook = WebhookClient(url)
49-
response = webhook.send({
50-
"text": "fallback",
51-
"blocks": [
50+
response = webhook.send(
51+
text="fallback",
52+
blocks=[
5253
SectionBlock(
5354
block_id="sb-id",
5455
text=MarkdownTextObject(text="This is a mrkdwn text section block."),
@@ -77,6 +78,155 @@ def test_with_block_kit_classes(self):
7778
],
7879
),
7980
]
80-
})
81+
)
82+
self.assertEqual(200, response.status_code)
83+
self.assertEqual("ok", response.body)
84+
85+
def test_with_blocks_dict(self):
86+
url = os.environ[SLACK_SDK_TEST_INCOMING_WEBHOOK_URL]
87+
webhook = WebhookClient(url)
88+
response = webhook.send(
89+
text="fallback",
90+
blocks=[
91+
{
92+
"type": "section",
93+
"block_id": "sb-id",
94+
"text": {
95+
"type": "mrkdwn",
96+
"text": "This is a mrkdwn text section block.",
97+
},
98+
"fields": [
99+
{
100+
"type": "plain_text",
101+
"text": "*this is plain_text text*",
102+
},
103+
{
104+
"type": "mrkdwn",
105+
"text": "*this is mrkdwn text*",
106+
},
107+
{
108+
"type": "plain_text",
109+
"text": "*this is plain_text text*",
110+
}
111+
]
112+
},
113+
{
114+
"type": "divider",
115+
"block_id": "9SxG"
116+
},
117+
{
118+
"type": "actions",
119+
"block_id": "avJ",
120+
"elements": [
121+
{
122+
"type": "button",
123+
"action_id": "yXqIx",
124+
"text": {
125+
"type": "plain_text",
126+
"text": "Create New Task",
127+
},
128+
"style": "primary",
129+
"value": "create_task"
130+
},
131+
{
132+
"type": "button",
133+
"action_id": "KCdDw",
134+
"text": {
135+
"type": "plain_text",
136+
"text": "Create New Project",
137+
},
138+
"value": "create_project"
139+
},
140+
{
141+
"type": "button",
142+
"action_id": "MXjB",
143+
"text": {
144+
"type": "plain_text",
145+
"text": "Help",
146+
},
147+
"value": "help"
148+
}
149+
]
150+
}
151+
]
152+
)
153+
self.assertEqual(200, response.status_code)
154+
self.assertEqual("ok", response.body)
155+
156+
def test_with_attachments(self):
157+
url = os.environ[SLACK_SDK_TEST_INCOMING_WEBHOOK_URL]
158+
webhook = WebhookClient(url)
159+
response = webhook.send(
160+
text="fallback",
161+
attachments=[
162+
Attachment(
163+
text="attachment text",
164+
title="Attachment",
165+
fallback="fallback_text",
166+
pretext="some_pretext",
167+
title_link="link in title",
168+
fields=[
169+
AttachmentField(title=f"field_{i}_title", value=f"field_{i}_value")
170+
for i in range(5)
171+
],
172+
color="#FFFF00",
173+
author_name="John Doe",
174+
author_link="http://johndoeisthebest.com",
175+
author_icon="http://johndoeisthebest.com/avatar.jpg",
176+
thumb_url="thumbnail URL",
177+
footer="and a footer",
178+
footer_icon="link to footer icon",
179+
ts=123456789,
180+
markdown_in=["fields"],
181+
)
182+
]
183+
)
184+
self.assertEqual(200, response.status_code)
185+
self.assertEqual("ok", response.body)
186+
187+
def test_with_attachments_dict(self):
188+
url = os.environ[SLACK_SDK_TEST_INCOMING_WEBHOOK_URL]
189+
webhook = WebhookClient(url)
190+
response = webhook.send(
191+
text="fallback",
192+
attachments=[
193+
{
194+
"author_name": "John Doe",
195+
"fallback": "fallback_text",
196+
"text": "attachment text",
197+
"pretext": "some_pretext",
198+
"title": "Attachment",
199+
"footer": "and a footer",
200+
"id": 1,
201+
"author_link": "http://johndoeisthebest.com",
202+
"color": "FFFF00",
203+
"fields": [
204+
{
205+
"title": "field_0_title",
206+
"value": "field_0_value",
207+
},
208+
{
209+
"title": "field_1_title",
210+
"value": "field_1_value",
211+
},
212+
{
213+
"title": "field_2_title",
214+
"value": "field_2_value",
215+
},
216+
{
217+
"title": "field_3_title",
218+
"value": "field_3_value",
219+
},
220+
{
221+
"title": "field_4_title",
222+
"value": "field_4_value",
223+
}
224+
],
225+
"mrkdwn_in": [
226+
"fields"
227+
]
228+
}
229+
]
230+
)
81231
self.assertEqual(200, response.status_code)
82232
self.assertEqual("ok", response.body)

slack/webhook/client.py

Lines changed: 80 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import json
22
import logging
33
from http.client import HTTPResponse
4-
from typing import Dict, Union
4+
from typing import Dict, Union, List, Optional
55
from urllib.error import HTTPError
66
from urllib.request import Request, urlopen
77

88
from slack.errors import SlackRequestError
99
from .webhook_response import WebhookResponse
1010
from ..web import convert_bool_to_0_or_1, get_user_agent
11+
from ..web.classes.attachments import Attachment
1112
from ..web.classes.blocks import Block
1213

1314

@@ -25,27 +26,46 @@ def __init__(
2526
self.default_headers = default_headers
2627

2728
def send(
28-
self, body: Dict[str, any], additional_headers: Dict[str, str] = {},
29+
self,
30+
*,
31+
text: Optional[str] = None,
32+
attachments: Optional[List[Union[Dict[str, any], Attachment]]] = None,
33+
blocks: Optional[List[Union[Dict[str, any], Block]]] = None,
34+
response_type: Optional[str] = None,
35+
headers: Optional[Dict[str, str]] = None,
36+
) -> WebhookResponse:
37+
"""Performs a Slack API request and returns the result.
38+
:param text: the text message (even when having blocks, setting this as well is recommended as it works as fallback)
39+
:param attachments: a collection of attachments
40+
:param blocks: a collection of Block Kit UI components
41+
:param response_type: the type of message (either 'in_channel' or 'ephemeral')
42+
:param headers: request headers to append only for this request
43+
:return: API response
44+
"""
45+
return self.send_dict(
46+
body={
47+
"text": text,
48+
"attachments": attachments,
49+
"blocks": blocks,
50+
"response_type": response_type,
51+
},
52+
headers=headers,
53+
)
54+
55+
def send_dict(
56+
self, body: Dict[str, any], headers: Optional[Dict[str, str]] = None
2957
) -> WebhookResponse:
3058
"""Performs a Slack API request and returns the result.
3159
:param body: json data structure (it's still a dict at this point),
3260
if you give this argument, body_params and files will be skipped
33-
:param additional_headers: request headers to append only for this request
61+
:param headers: request headers to append only for this request
3462
:return: API response
3563
"""
64+
body = {k: v for k, v in body.items() if v is not None}
3665
body = convert_bool_to_0_or_1(body)
37-
self._parse_blocks(body)
38-
if self.logger.level <= logging.DEBUG:
39-
self.logger.debug(
40-
f"Sending a request - url: {self.url}, "
41-
f"body: {body}, "
42-
f"additional_headers: {additional_headers}"
43-
)
44-
66+
self._parse_web_class_objects(body)
4567
return self._perform_http_request(
46-
url=self.url,
47-
body=body,
48-
headers=self._build_request_headers(additional_headers),
68+
url=self.url, body=body, headers=self._build_request_headers(headers),
4969
)
5070

5171
def _perform_http_request(
@@ -57,43 +77,56 @@ def _perform_http_request(
5777
:param headers: complete set of request headers
5878
:return: API response
5979
"""
60-
body = json.dumps(body).encode("utf-8")
80+
body = json.dumps(body)
6181
headers["Content-Type"] = "application/json;charset=utf-8"
6282

83+
if self.logger.level <= logging.DEBUG:
84+
self.logger.debug(
85+
f"Sending a request - url: {self.url}, body: {body}, headers: {headers}"
86+
)
6387
try:
6488
# for security
6589
if url.lower().startswith("http"):
66-
req = Request(method="POST", url=url, data=body, headers=headers)
90+
req = Request(
91+
method="POST", url=url, data=body.encode("utf-8"), headers=headers
92+
)
6793
else:
6894
raise SlackRequestError(f"Invalid URL detected: {url}")
6995

7096
resp: HTTPResponse = urlopen(req)
71-
charset = resp.headers.get_content_charset() or "utf-8"
72-
return WebhookResponse(
97+
charset: str = resp.headers.get_content_charset() or "utf-8"
98+
response_body: str = resp.read().decode(charset)
99+
resp = WebhookResponse(
73100
url=self.url,
74101
status_code=resp.status,
75-
body=resp.read().decode(charset),
102+
body=response_body,
76103
headers=resp.headers,
77104
)
105+
self._debug_log_response(resp)
106+
return resp
107+
78108
except HTTPError as e:
79-
charset = e.headers.get_content_charset() or "utf-8"
109+
charset: str = e.headers.get_content_charset() or "utf-8"
110+
response_body: str = resp.read().decode(charset)
80111
resp = WebhookResponse(
81-
url=self.url,
82-
status_code=e.code,
83-
body=e.read().decode(charset),
84-
headers=e.headers,
112+
url=self.url, status_code=e.code, body=response_body, headers=e.headers,
85113
)
86114
if e.code == 429:
115+
# for backward-compatibility with WebClient (v.2.5.0 or older)
87116
resp.headers["Retry-After"] = resp.headers["retry-after"]
117+
self._debug_log_response(resp)
88118
return resp
89119

90120
except Exception as err:
91121
self.logger.error(f"Failed to send a request to Slack API server: {err}")
92122
raise err
93123

94124
def _build_request_headers(
95-
self, additional_headers: Dict[str, str],
125+
self, additional_headers: Optional[Dict[str, str]],
96126
) -> Dict[str, str]:
127+
if additional_headers is None:
128+
return {}
129+
97130
request_headers = {
98131
"User-Agent": get_user_agent(),
99132
"Content-Type": "application/json;charset=utf-8",
@@ -104,14 +137,30 @@ def _build_request_headers(
104137
return request_headers
105138

106139
@staticmethod
107-
def _parse_blocks(body) -> None:
108-
blocks = body.get("blocks", None)
140+
def _parse_web_class_objects(body) -> None:
141+
def to_dict(obj: Union[Dict, Block, Attachment]):
142+
if isinstance(obj, Block):
143+
return obj.to_dict()
144+
if isinstance(obj, Attachment):
145+
return obj.to_dict()
146+
return obj
109147

110-
def to_dict(b: Union[Dict, Block]):
111-
if isinstance(b, Block):
112-
return b.to_dict()
113-
return b
148+
blocks = body.get("blocks", None)
114149

115150
if blocks is not None and isinstance(blocks, list):
116151
dict_blocks = [to_dict(b) for b in blocks]
117152
body.update({"blocks": dict_blocks})
153+
154+
attachments = body.get("attachments", None)
155+
if attachments is not None and isinstance(attachments, list):
156+
dict_attachments = [to_dict(a) for a in attachments]
157+
body.update({"attachments": dict_attachments})
158+
159+
def _debug_log_response(self, resp: WebhookResponse) -> None:
160+
if self.logger.level <= logging.DEBUG:
161+
self.logger.debug(
162+
"Received the following response - "
163+
f"status: {resp.status_code}, "
164+
f"headers: {(dict(resp.headers))}, "
165+
f"body: {resp.body}"
166+
)

0 commit comments

Comments
 (0)