Skip to content

Commit cbf2383

Browse files
authored
Fix #754 by adding the async version of Tornado adapter (#758)
1 parent 3cdb1dc commit cbf2383

File tree

8 files changed

+340
-4
lines changed

8 files changed

+340
-4
lines changed

examples/tornado/app.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99

1010

1111
@app.middleware # or app.use(log_request)
12-
def log_request(logger, body, next):
12+
def log_request(logger, body, next_):
1313
logger.debug(body)
14-
return next()
14+
next_()
1515

1616

1717
@app.event("app_mention")

examples/tornado/async_app.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import logging
2+
3+
logging.basicConfig(level=logging.DEBUG)
4+
5+
from slack_bolt.async_app import AsyncApp
6+
from slack_bolt.adapter.tornado.async_handler import AsyncSlackEventsHandler
7+
8+
app = AsyncApp()
9+
10+
11+
@app.middleware # or app.use(log_request)
12+
async def log_request(logger, body, next_):
13+
logger.debug(body)
14+
await next_()
15+
16+
17+
@app.event("app_mention")
18+
async def event_test(body, say, logger):
19+
logger.info(body)
20+
await say("What's up?")
21+
22+
23+
from tornado.web import Application
24+
from tornado.ioloop import IOLoop
25+
26+
api = Application([("/slack/events", AsyncSlackEventsHandler, dict(app=app))])
27+
28+
if __name__ == "__main__":
29+
api.listen(3000)
30+
IOLoop.current().start()
31+
32+
# pip install -r requirements.txt
33+
# export SLACK_SIGNING_SECRET=***
34+
# export SLACK_BOT_TOKEN=xoxb-***
35+
# python async_app.py
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import logging
2+
from slack_bolt.async_app import AsyncApp
3+
from slack_bolt.adapter.tornado.async_handler import AsyncSlackEventsHandler, AsyncSlackOAuthHandler
4+
5+
logging.basicConfig(level=logging.DEBUG)
6+
app = AsyncApp()
7+
8+
9+
@app.middleware # or app.use(log_request)
10+
async def log_request(logger, body, next_):
11+
logger.debug(body)
12+
await next_()
13+
14+
15+
@app.event("app_mention")
16+
async def event_test(body, say, logger):
17+
logger.info(body)
18+
await say("What's up?")
19+
20+
21+
from tornado.web import Application
22+
from tornado.ioloop import IOLoop
23+
24+
api = Application(
25+
[
26+
("/slack/events", AsyncSlackEventsHandler, dict(app=app)),
27+
("/slack/install", AsyncSlackOAuthHandler, dict(app=app)),
28+
("/slack/oauth_redirect", AsyncSlackOAuthHandler, dict(app=app)),
29+
]
30+
)
31+
32+
if __name__ == "__main__":
33+
api.listen(3000)
34+
IOLoop.current().start()
35+
36+
# pip install -r requirements.txt
37+
38+
# # -- OAuth flow -- #
39+
# export SLACK_SIGNING_SECRET=***
40+
# export SLACK_BOT_TOKEN=xoxb-***
41+
# export SLACK_CLIENT_ID=111.111
42+
# export SLACK_CLIENT_SECRET=***
43+
# export SLACK_SCOPES=app_mentions:read,chat:write
44+
45+
# python async_oauth_app.py

examples/tornado/oauth_app.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@
99
@app.middleware # or app.use(log_request)
1010
def log_request(logger, body, next):
1111
logger.debug(body)
12-
return next()
12+
next()
1313

1414

1515
@app.event("app_mention")
16-
def event_test(ack, body, say, logger):
16+
def event_test(body, say, logger):
1717
logger.info(body)
1818
say("What's up?")
1919

slack_bolt/adapter/tornado/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
# Don't add async module imports here
12
from .handler import SlackEventsHandler, SlackOAuthHandler
23

34
__all__ = [
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from tornado.httputil import HTTPServerRequest
2+
from tornado.web import RequestHandler
3+
4+
from slack_bolt.async_app import AsyncApp
5+
from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow
6+
from slack_bolt.request.async_request import AsyncBoltRequest
7+
from slack_bolt.response import BoltResponse
8+
from .handler import set_response
9+
10+
11+
class AsyncSlackEventsHandler(RequestHandler):
12+
def initialize(self, app: AsyncApp): # type: ignore
13+
self.app = app
14+
15+
async def post(self):
16+
bolt_resp: BoltResponse = await self.app.async_dispatch(to_async_bolt_request(self.request))
17+
set_response(self, bolt_resp)
18+
return
19+
20+
21+
class AsyncSlackOAuthHandler(RequestHandler):
22+
def initialize(self, app: AsyncApp): # type: ignore
23+
self.app = app
24+
25+
async def get(self):
26+
if self.app.oauth_flow is not None: # type: ignore
27+
oauth_flow: AsyncOAuthFlow = self.app.oauth_flow # type: ignore
28+
if self.request.path == oauth_flow.install_path:
29+
bolt_resp = await oauth_flow.handle_installation(to_async_bolt_request(self.request))
30+
set_response(self, bolt_resp)
31+
return
32+
elif self.request.path == oauth_flow.redirect_uri_path:
33+
bolt_resp = await oauth_flow.handle_callback(to_async_bolt_request(self.request))
34+
set_response(self, bolt_resp)
35+
return
36+
self.set_status(404)
37+
38+
39+
def to_async_bolt_request(req: HTTPServerRequest) -> AsyncBoltRequest:
40+
return AsyncBoltRequest(
41+
body=req.body.decode("utf-8") if req.body else "",
42+
query=req.query,
43+
headers=req.headers,
44+
)
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import json
2+
from time import time
3+
from urllib.parse import quote
4+
5+
from slack_sdk.signature import SignatureVerifier
6+
from slack_sdk.web.async_client import AsyncWebClient
7+
from tornado.httpclient import HTTPRequest, HTTPResponse
8+
from tornado.testing import AsyncHTTPTestCase, gen_test
9+
from tornado.web import Application
10+
11+
from slack_bolt.adapter.tornado.async_handler import AsyncSlackEventsHandler
12+
from slack_bolt.async_app import AsyncApp
13+
from tests.mock_web_api_server import (
14+
setup_mock_web_api_server,
15+
cleanup_mock_web_api_server,
16+
assert_auth_test_count,
17+
)
18+
from tests.utils import remove_os_env_temporarily, restore_os_env
19+
20+
signing_secret = "secret"
21+
valid_token = "xoxb-valid"
22+
mock_api_server_base_url = "http://localhost:8888"
23+
24+
25+
async def event_handler():
26+
pass
27+
28+
29+
async def shortcut_handler(ack):
30+
await ack()
31+
32+
33+
async def command_handler(ack):
34+
await ack()
35+
36+
37+
class TestTornado(AsyncHTTPTestCase):
38+
signature_verifier = SignatureVerifier(signing_secret)
39+
40+
def setUp(self):
41+
self.old_os_env = remove_os_env_temporarily()
42+
setup_mock_web_api_server(self)
43+
44+
web_client = AsyncWebClient(
45+
token=valid_token,
46+
base_url=mock_api_server_base_url,
47+
)
48+
self.app = AsyncApp(
49+
client=web_client,
50+
signing_secret=signing_secret,
51+
)
52+
self.app.event("app_mention")(event_handler)
53+
self.app.shortcut("test-shortcut")(shortcut_handler)
54+
self.app.command("/hello-world")(command_handler)
55+
56+
AsyncHTTPTestCase.setUp(self)
57+
58+
def tearDown(self):
59+
AsyncHTTPTestCase.tearDown(self)
60+
cleanup_mock_web_api_server(self)
61+
restore_os_env(self.old_os_env)
62+
63+
def get_app(self):
64+
return Application([("/slack/events", AsyncSlackEventsHandler, dict(app=self.app))])
65+
66+
def generate_signature(self, body: str, timestamp: str):
67+
return self.signature_verifier.generate_signature(
68+
body=body,
69+
timestamp=timestamp,
70+
)
71+
72+
def build_headers(self, timestamp: str, body: str):
73+
content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded"
74+
return {
75+
"content-type": content_type,
76+
"x-slack-signature": self.generate_signature(body, timestamp),
77+
"x-slack-request-timestamp": timestamp,
78+
}
79+
80+
@gen_test
81+
async def test_events(self):
82+
input = {
83+
"token": "verification_token",
84+
"team_id": "T111",
85+
"enterprise_id": "E111",
86+
"api_app_id": "A111",
87+
"event": {
88+
"client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63",
89+
"type": "app_mention",
90+
"text": "<@W111> Hi there!",
91+
"user": "W222",
92+
"ts": "1595926230.009600",
93+
"team": "T111",
94+
"channel": "C111",
95+
"event_ts": "1595926230.009600",
96+
},
97+
"type": "event_callback",
98+
"event_id": "Ev111",
99+
"event_time": 1595926230,
100+
"authed_users": ["W111"],
101+
}
102+
timestamp, body = str(int(time())), json.dumps(input)
103+
104+
request = HTTPRequest(
105+
url=self.get_url("/slack/events"),
106+
method="POST",
107+
body=body,
108+
headers=self.build_headers(timestamp, body),
109+
)
110+
response: HTTPResponse = await self.http_client.fetch(request)
111+
assert response.code == 200
112+
assert_auth_test_count(self, 1)
113+
114+
@gen_test
115+
async def test_shortcuts(self):
116+
input = {
117+
"type": "shortcut",
118+
"token": "verification_token",
119+
"action_ts": "111.111",
120+
"team": {
121+
"id": "T111",
122+
"domain": "workspace-domain",
123+
"enterprise_id": "E111",
124+
"enterprise_name": "Org Name",
125+
},
126+
"user": {"id": "W111", "username": "primary-owner", "team_id": "T111"},
127+
"callback_id": "test-shortcut",
128+
"trigger_id": "111.111.xxxxxx",
129+
}
130+
131+
timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}"
132+
133+
request = HTTPRequest(
134+
url=self.get_url("/slack/events"),
135+
method="POST",
136+
body=body,
137+
headers=self.build_headers(timestamp, body),
138+
)
139+
response: HTTPResponse = await self.http_client.fetch(request)
140+
assert response.code == 200
141+
assert_auth_test_count(self, 1)
142+
143+
@gen_test
144+
async def test_commands(self):
145+
input = (
146+
"token=verification_token"
147+
"&team_id=T111"
148+
"&team_domain=test-domain"
149+
"&channel_id=C111"
150+
"&channel_name=random"
151+
"&user_id=W111"
152+
"&user_name=primary-owner"
153+
"&command=%2Fhello-world"
154+
"&text=Hi"
155+
"&enterprise_id=E111"
156+
"&enterprise_name=Org+Name"
157+
"&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx"
158+
"&trigger_id=111.111.xxx"
159+
)
160+
timestamp, body = str(int(time())), input
161+
162+
request = HTTPRequest(
163+
url=self.get_url("/slack/events"),
164+
method="POST",
165+
body=body,
166+
headers=self.build_headers(timestamp, body),
167+
)
168+
response: HTTPResponse = await self.http_client.fetch(request)
169+
assert response.code == 200
170+
assert_auth_test_count(self, 1)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from tornado.httpclient import HTTPRequest, HTTPResponse, HTTPClientError
2+
from tornado.testing import AsyncHTTPTestCase, gen_test
3+
from tornado.web import Application
4+
5+
from slack_bolt.adapter.tornado.async_handler import AsyncSlackOAuthHandler
6+
from slack_bolt.async_app import AsyncApp
7+
from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings
8+
from tests.utils import remove_os_env_temporarily, restore_os_env
9+
10+
signing_secret = "secret"
11+
12+
app = AsyncApp(
13+
signing_secret=signing_secret,
14+
oauth_settings=AsyncOAuthSettings(
15+
client_id="111.111",
16+
client_secret="xxx",
17+
scopes=["chat:write", "commands"],
18+
),
19+
)
20+
21+
22+
class TestTornado(AsyncHTTPTestCase):
23+
def get_app(self):
24+
return Application([("/slack/install", AsyncSlackOAuthHandler, dict(app=app))])
25+
26+
def setUp(self):
27+
AsyncHTTPTestCase.setUp(self)
28+
self.old_os_env = remove_os_env_temporarily()
29+
30+
def tearDown(self):
31+
AsyncHTTPTestCase.tearDown(self)
32+
restore_os_env(self.old_os_env)
33+
34+
@gen_test
35+
async def test_oauth(self):
36+
request = HTTPRequest(url=self.get_url("/slack/install"), method="GET", follow_redirects=False)
37+
try:
38+
response: HTTPResponse = await self.http_client.fetch(request)
39+
assert response.code == 200
40+
except HTTPClientError as e:
41+
assert e.code == 200

0 commit comments

Comments
 (0)