Skip to content

Commit c7d0be8

Browse files
committed
Fix #270 by adding a new module for incoming webhook and response_url
1 parent 86f77bc commit c7d0be8

File tree

10 files changed

+339
-26
lines changed

10 files changed

+339
-26
lines changed

integration_tests/env_variable_names.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,7 @@
2323
SLACK_SDK_TEST_GRID_WORKSPACE_BOT_TOKEN = "SLACK_SDK_TEST_GRID_WORKSPACE_BOT_TOKEN"
2424
SLACK_SDK_TEST_GRID_IDP_USERGROUP_ID = "SLACK_SDK_TEST_GRID_IDP_USERGROUP_ID"
2525
SLACK_SDK_TEST_GRID_TEAM_ID = "SLACK_SDK_TEST_GRID_TEAM_ID"
26+
27+
# Webhook
28+
SLACK_SDK_TEST_INCOMING_WEBHOOK_URL = "SLACK_SDK_TEST_INCOMING_WEBHOOK_URL"
29+
SLACK_SDK_TEST_INCOMING_WEBHOOK_CHANNEL_NAME = "SLACK_SDK_TEST_INCOMING_WEBHOOK_CHANNEL_NAME"
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import os
2+
import unittest
3+
4+
from integration_tests.env_variable_names import SLACK_SDK_TEST_INCOMING_WEBHOOK_URL, \
5+
SLACK_SDK_TEST_INCOMING_WEBHOOK_CHANNEL_NAME, \
6+
SLACK_SDK_TEST_BOT_TOKEN
7+
from slack import WebClient
8+
from slack import WebhookClient
9+
from slack.web.classes.blocks import SectionBlock, DividerBlock, ActionsBlock
10+
from slack.web.classes.elements import ButtonElement
11+
from slack.web.classes.objects import MarkdownTextObject, PlainTextObject
12+
13+
14+
class TestWebhook(unittest.TestCase):
15+
16+
def setUp(self):
17+
pass
18+
19+
def tearDown(self):
20+
pass
21+
22+
def test_webhook(self):
23+
url = os.environ[SLACK_SDK_TEST_INCOMING_WEBHOOK_URL]
24+
webhook = WebhookClient(url)
25+
response = webhook.send({"text": "Hello!"})
26+
self.assertEqual(200, response.status_code)
27+
self.assertEqual("ok", response.body)
28+
29+
token = os.environ[SLACK_SDK_TEST_BOT_TOKEN]
30+
channel_name = os.environ[SLACK_SDK_TEST_INCOMING_WEBHOOK_CHANNEL_NAME].replace("#", "")
31+
client = WebClient(token=token)
32+
channel_id = None
33+
for resp in client.conversations_list(limit=10):
34+
for c in resp["channels"]:
35+
if c["name"] == channel_name:
36+
channel_id = c["id"]
37+
break
38+
if channel_id is not None:
39+
break
40+
41+
history = client.conversations_history(channel=channel_id, limit=1)
42+
self.assertIsNotNone(history)
43+
actual_text = history["messages"][0]["text"]
44+
self.assertEqual("Hello!", actual_text)
45+
46+
def test_with_block_kit_classes(self):
47+
url = os.environ[SLACK_SDK_TEST_INCOMING_WEBHOOK_URL]
48+
webhook = WebhookClient(url)
49+
response = webhook.send({
50+
"text": "fallback",
51+
"blocks": [
52+
SectionBlock(
53+
block_id="sb-id",
54+
text=MarkdownTextObject(text="This is a mrkdwn text section block."),
55+
fields=[
56+
PlainTextObject(text="*this is plain_text text*", emoji=True),
57+
MarkdownTextObject(text="*this is mrkdwn text*"),
58+
PlainTextObject(text="*this is plain_text text*", emoji=True),
59+
]
60+
),
61+
DividerBlock(),
62+
ActionsBlock(
63+
elements=[
64+
ButtonElement(
65+
text=PlainTextObject(text="Create New Task", emoji=True),
66+
style="primary",
67+
value="create_task",
68+
),
69+
ButtonElement(
70+
text=PlainTextObject(text="Create New Project", emoji=True),
71+
value="create_project",
72+
),
73+
ButtonElement(
74+
text=PlainTextObject(text="Help", emoji=True),
75+
value="help",
76+
),
77+
],
78+
),
79+
]
80+
})
81+
self.assertEqual(200, response.status_code)
82+
self.assertEqual("ok", response.body)

slack/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
from slack.web.client import WebClient # noqa
55
from slack.rtm.client import RTMClient # noqa
6+
from slack.webhook.client import WebhookClient # noqa
67

78
# Set default logging handler to avoid "No handler found" warnings.
89
logging.getLogger(__name__).addHandler(NullHandler())

slack/web/__init__.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import platform
2+
import sys
13
from typing import Dict
24

3-
4-
# ---------------------------------------
5+
import slack.version as slack_version
56

67

78
def _to_0_or_1_if_bool(v: any) -> str:
@@ -23,3 +24,19 @@ def convert_bool_to_0_or_1(params: Dict[str, any]) -> Dict[str, any]:
2324
if params:
2425
return {k: _to_0_or_1_if_bool(v) for k, v in params.items()}
2526
return None
27+
28+
29+
def get_user_agent():
30+
"""Construct the user-agent header with the package info,
31+
Python version and OS version.
32+
33+
Returns:
34+
The user agent string.
35+
e.g. 'Python/3.6.7 slackclient/2.0.0 Darwin/17.7.0'
36+
"""
37+
# __name__ returns all classes, we only want the client
38+
client = "{0}/{1}".format("slackclient", slack_version.__version__)
39+
python_version = "Python/{v.major}.{v.minor}.{v.micro}".format(v=sys.version_info)
40+
system_info = "{0}/{1}".format(platform.system(), platform.release())
41+
user_agent_string = " ".join([python_version, client, system_info])
42+
return user_agent_string

slack/web/base_client.py

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
import logging
1010
import mimetypes
1111
import os
12-
import platform
13-
import sys
1412
import uuid
1513
import warnings
1614
from http.client import HTTPResponse
@@ -25,9 +23,8 @@
2523
from aiohttp import FormData, BasicAuth
2624

2725
import slack.errors as err
28-
import slack.version as slack_version
2926
from slack.errors import SlackRequestError
30-
from slack.web import convert_bool_to_0_or_1
27+
from slack.web import convert_bool_to_0_or_1, get_user_agent
3128
from slack.web.classes.blocks import Block
3229
from slack.web.slack_response import SlackResponse
3330

@@ -92,7 +89,7 @@ def _get_headers(
9289
}
9390
"""
9491
final_headers = {
95-
"User-Agent": self._get_user_agent(),
92+
"User-Agent": get_user_agent(),
9693
"Content-Type": "application/x-www-form-urlencoded",
9794
}
9895

@@ -580,7 +577,7 @@ def _build_urllib_request_headers(
580577
self, token: str, has_json: bool, has_files: bool, additional_headers: dict,
581578
):
582579
headers = {
583-
"User-Agent": self._get_user_agent(),
580+
"User-Agent": get_user_agent(),
584581
"Content-Type": "application/x-www-form-urlencoded",
585582
}
586583
headers.update(self.headers)
@@ -597,24 +594,6 @@ def _build_urllib_request_headers(
597594

598595
# =================================================================
599596

600-
@staticmethod
601-
def _get_user_agent():
602-
"""Construct the user-agent header with the package info,
603-
Python version and OS version.
604-
605-
Returns:
606-
The user agent string.
607-
e.g. 'Python/3.6.7 slackclient/2.0.0 Darwin/17.7.0'
608-
"""
609-
# __name__ returns all classes, we only want the client
610-
client = "{0}/{1}".format("slackclient", slack_version.__version__)
611-
python_version = "Python/{v.major}.{v.minor}.{v.micro}".format(
612-
v=sys.version_info
613-
)
614-
system_info = "{0}/{1}".format(platform.system(), platform.release())
615-
user_agent_string = " ".join([python_version, client, system_info])
616-
return user_agent_string
617-
618597
@staticmethod
619598
def validate_slack_signature(
620599
*, signing_secret: str, data: str, timestamp: str, signature: str

slack/webhook/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .webhook_response import WebhookResponse # noqa
2+
from .client import WebhookClient # noqa

slack/webhook/client.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import json
2+
import logging
3+
from http.client import HTTPResponse
4+
from typing import Dict, Union
5+
from urllib.error import HTTPError
6+
from urllib.request import Request, urlopen
7+
8+
from slack.errors import SlackRequestError
9+
from .webhook_response import WebhookResponse
10+
from ..web import convert_bool_to_0_or_1, get_user_agent
11+
from ..web.classes.blocks import Block
12+
13+
14+
class WebhookClient:
15+
logger = logging.getLogger(__name__)
16+
17+
def __init__(
18+
self, url: str, default_headers: Dict[str, str] = {},
19+
):
20+
"""urllib-based API client.
21+
:param default_headers: request headers to add to all requests
22+
"""
23+
self.url = url
24+
self.default_headers = default_headers
25+
26+
def send(
27+
self, body: Dict[str, any], additional_headers: Dict[str, str] = {},
28+
) -> WebhookResponse:
29+
"""Performs a Slack API request and returns the result.
30+
:param url: a complete URL (e.g., https://hooks.slack.com/XXX)
31+
:param json_body: json data structure (it's still a dict at this point),
32+
if you give this argument, body_params and files will be skipped
33+
:param body_params: form params
34+
:param additional_headers: request headers to append
35+
:return: API response
36+
"""
37+
38+
body = convert_bool_to_0_or_1(body)
39+
self._parse_blocks(body)
40+
if self.logger.level <= logging.DEBUG:
41+
self.logger.debug(
42+
f"Slack API Request - url: {self.url}, "
43+
f"body: {body}, "
44+
f"additional_headers: {additional_headers}"
45+
)
46+
47+
request_headers = self._build_request_headers(
48+
has_json=json is not None, additional_headers=additional_headers,
49+
)
50+
args = {
51+
"headers": request_headers,
52+
"body": body,
53+
}
54+
return self._perform_http_request(url=self.url, args=args)
55+
56+
def _perform_http_request(
57+
self, *, url: str, args: Dict[str, Dict[str, any]]
58+
) -> WebhookResponse:
59+
"""Performs an HTTP request and parses the response.
60+
:param url: a complete URL (e.g., https://www.slack.com/api/chat.postMessage)
61+
:param args: args has "headers", "data", "params", and "json"
62+
"headers": Dict[str, str]
63+
"params": Dict[str, str],
64+
"json": Dict[str, any],
65+
:return: a tuple (HTTP response and its body)
66+
"""
67+
headers = args["headers"]
68+
body = json.dumps(args["body"]).encode("utf-8")
69+
headers["Content-Type"] = "application/json;charset=utf-8"
70+
71+
try:
72+
if url.lower().startswith("http"):
73+
req = Request(method="POST", url=url, data=body, headers=headers)
74+
else:
75+
raise SlackRequestError(f"Invalid URL detected: {url}")
76+
resp: HTTPResponse = urlopen(req)
77+
charset = resp.headers.get_content_charset() or "utf-8"
78+
return WebhookResponse(
79+
url=self.url,
80+
status_code=resp.status,
81+
body=resp.read().decode(charset),
82+
headers=resp.headers,
83+
)
84+
except HTTPError as e:
85+
charset = e.headers.get_content_charset() or "utf-8"
86+
resp = WebhookResponse(
87+
url=self.url,
88+
status_code=e.code,
89+
body=e.read().decode(charset),
90+
headers=e.headers,
91+
)
92+
if e.code == 429:
93+
resp.headers["Retry-After"] = resp.headers["retry-after"]
94+
return resp
95+
96+
except Exception as err:
97+
self.logger.error(f"Failed to send a request to Slack API server: {err}")
98+
raise err
99+
100+
def _build_request_headers(
101+
self, has_json: bool, additional_headers: dict,
102+
):
103+
headers = {
104+
"User-Agent": get_user_agent(),
105+
"Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
106+
}
107+
headers.update(self.default_headers)
108+
if additional_headers:
109+
headers.update(additional_headers)
110+
if has_json:
111+
headers.update({"Content-Type": "application/json;charset=utf-8"})
112+
return headers
113+
114+
@staticmethod
115+
def _parse_blocks(body):
116+
blocks = body.get("blocks", None)
117+
118+
def to_dict(b: Union[Dict, Block]):
119+
if isinstance(b, Block):
120+
return b.to_dict()
121+
return b
122+
123+
if blocks is not None and isinstance(blocks, list):
124+
dict_blocks = [to_dict(b) for b in blocks]
125+
body.update({"blocks": dict_blocks})

slack/webhook/webhook_response.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
class WebhookResponse:
2+
def __init__(
3+
self, *, url: str, status_code: int, body: str, headers: dict,
4+
):
5+
self.api_url = url
6+
self.status_code = status_code
7+
self.body = body
8+
self.headers = headers

0 commit comments

Comments
 (0)