Skip to content

Commit 911e21d

Browse files
Add ASGI Falcon App support (#614)
Co-authored-by: Kazuhiro Sera <[email protected]>
1 parent 837c773 commit 911e21d

File tree

7 files changed

+499
-1
lines changed

7 files changed

+499
-1
lines changed

.github/workflows/tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,5 @@ jobs:
7070
- name: Run tests for HTTP Mode adapters (asyncio-based libraries)
7171
run: |
7272
pip install -e ".[async]"
73+
pip install "falcon>=3,<4"
7374
pytest tests/adapter_tests_async/

examples/falcon/async_app.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import falcon
2+
import logging
3+
import re
4+
from slack_bolt.async_app import AsyncApp, AsyncRespond, AsyncAck
5+
from slack_bolt.adapter.falcon import AsyncSlackAppResource
6+
7+
logging.basicConfig(level=logging.DEBUG)
8+
app = AsyncApp()
9+
10+
11+
# @app.command("/bolt-py-proto", [lambda body: body["team_id"] == "T03E94MJU"])
12+
async def test_command(logger: logging.Logger, body: dict, ack: AsyncAck, respond: AsyncRespond):
13+
logger.info(body)
14+
await ack("thanks!")
15+
await respond(
16+
blocks=[
17+
{
18+
"type": "section",
19+
"block_id": "b",
20+
"text": {
21+
"type": "mrkdwn",
22+
"text": "You can add a button alongside text in your message. ",
23+
},
24+
"accessory": {
25+
"type": "button",
26+
"action_id": "a",
27+
"text": {"type": "plain_text", "text": "Button"},
28+
"value": "click_me_123",
29+
},
30+
}
31+
]
32+
)
33+
34+
35+
app.command(re.compile(r"/hello-bolt-.+"))(test_command)
36+
37+
38+
@app.shortcut("test-shortcut")
39+
async def test_shortcut(ack, client, logger, body):
40+
logger.info(body)
41+
await ack()
42+
res = await client.views_open(
43+
trigger_id=body["trigger_id"],
44+
view={
45+
"type": "modal",
46+
"callback_id": "view-id",
47+
"title": {
48+
"type": "plain_text",
49+
"text": "My App",
50+
},
51+
"submit": {
52+
"type": "plain_text",
53+
"text": "Submit",
54+
},
55+
"close": {
56+
"type": "plain_text",
57+
"text": "Cancel",
58+
},
59+
"blocks": [
60+
{
61+
"type": "input",
62+
"element": {"type": "plain_text_input"},
63+
"label": {
64+
"type": "plain_text",
65+
"text": "Label",
66+
},
67+
}
68+
],
69+
},
70+
)
71+
logger.info(res)
72+
73+
74+
@app.view("view-id")
75+
async def view_submission(ack, body, logger):
76+
logger.info(body)
77+
await ack()
78+
79+
80+
@app.action("a")
81+
async def button_click(logger, action, ack, respond):
82+
logger.info(action)
83+
await ack()
84+
await respond("Here is my response")
85+
86+
87+
@app.event("app_mention")
88+
async def handle_app_mentions(body, say, logger):
89+
logger.info(body)
90+
await say("What's up?")
91+
92+
93+
api = falcon.asgi.App()
94+
resource = AsyncSlackAppResource(app)
95+
api.add_route("/slack/events", resource)
96+
97+
# pip install -r requirements.txt
98+
# export SLACK_SIGNING_SECRET=***
99+
# export SLACK_BOT_TOKEN=xoxb-***
100+
# uvicorn --reload -h 0.0.0.0 -p 3000 async_app:api

examples/falcon/async_oauth_app.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import falcon
2+
import logging
3+
import re
4+
from slack_bolt.async_app import AsyncApp, AsyncRespond, AsyncAck
5+
from slack_bolt.adapter.falcon import AsyncSlackAppResource
6+
7+
logging.basicConfig(level=logging.DEBUG)
8+
app = AsyncApp()
9+
10+
11+
# @app.command("/bolt-py-proto", [lambda body: body["team_id"] == "T03E94MJU"])
12+
async def test_command(logger: logging.Logger, body: dict, ack: AsyncAck, respond: AsyncRespond):
13+
logger.info(body)
14+
await ack("thanks!")
15+
await respond(
16+
blocks=[
17+
{
18+
"type": "section",
19+
"block_id": "b",
20+
"text": {
21+
"type": "mrkdwn",
22+
"text": "You can add a button alongside text in your message. ",
23+
},
24+
"accessory": {
25+
"type": "button",
26+
"action_id": "a",
27+
"text": {"type": "plain_text", "text": "Button"},
28+
"value": "click_me_123",
29+
},
30+
}
31+
]
32+
)
33+
34+
35+
app.command(re.compile(r"/hello-bolt-.+"))(test_command)
36+
37+
38+
@app.shortcut("test-shortcut")
39+
async def test_shortcut(ack, client, logger, body):
40+
logger.info(body)
41+
await ack()
42+
res = await client.views_open(
43+
trigger_id=body["trigger_id"],
44+
view={
45+
"type": "modal",
46+
"callback_id": "view-id",
47+
"title": {
48+
"type": "plain_text",
49+
"text": "My App",
50+
},
51+
"submit": {
52+
"type": "plain_text",
53+
"text": "Submit",
54+
},
55+
"close": {
56+
"type": "plain_text",
57+
"text": "Cancel",
58+
},
59+
"blocks": [
60+
{
61+
"type": "input",
62+
"element": {"type": "plain_text_input"},
63+
"label": {
64+
"type": "plain_text",
65+
"text": "Label",
66+
},
67+
}
68+
],
69+
},
70+
)
71+
logger.info(res)
72+
73+
74+
@app.view("view-id")
75+
async def view_submission(ack, body, logger):
76+
logger.info(body)
77+
await ack()
78+
79+
80+
@app.action("a")
81+
async def button_click(logger, action, ack, respond):
82+
logger.info(action)
83+
await ack()
84+
await respond("Here is my response")
85+
86+
87+
@app.event("app_mention")
88+
async def handle_app_mentions(body, say, logger):
89+
logger.info(body)
90+
await say("What's up?")
91+
92+
93+
api = falcon.asgi.App()
94+
resource = AsyncSlackAppResource(app)
95+
api.add_route("/slack/events", resource)
96+
97+
# pip install -r requirements.txt
98+
# export SLACK_SIGNING_SECRET=***
99+
# export SLACK_BOT_TOKEN=xoxb-***
100+
# uvicorn --reload -h 0.0.0.0 -p 3000 async_oauth_app:api
101+
api.add_route("/slack/install", resource)
102+
api.add_route("/slack/oauth_redirect", resource)

examples/falcon/requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
falcon>=2,<3
2-
gunicorn>=20,<21
2+
gunicorn>=20,<21
3+
uvicorn
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
# Don't add async module imports here
12
from .resource import SlackAppResource
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
from datetime import datetime # type: ignore
2+
from http import HTTPStatus
3+
4+
from falcon import version as falcon_version
5+
from falcon.asgi import Request, Response
6+
from slack_bolt import BoltResponse
7+
from slack_bolt.async_app import AsyncApp
8+
from slack_bolt.error import BoltError
9+
from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow
10+
from slack_bolt.request.async_request import AsyncBoltRequest
11+
12+
13+
class AsyncSlackAppResource:
14+
"""
15+
For use with ASGI Falcon Apps.
16+
17+
from slack_bolt.async_app import AsyncApp
18+
app = AsyncApp()
19+
20+
import falcon
21+
app = falcon.asgi.App()
22+
app.add_route("/slack/events", AsyncSlackAppResource(app))
23+
"""
24+
25+
def __init__(self, app: AsyncApp): # type: ignore
26+
if falcon_version.__version__.startswith("2."):
27+
raise BoltError("This ASGI compatible adapter requires Falcon version >= 3.0")
28+
29+
self.app = app
30+
31+
async def on_get(self, req: Request, resp: Response):
32+
if self.app.oauth_flow is not None:
33+
oauth_flow: AsyncOAuthFlow = self.app.oauth_flow
34+
if req.path == oauth_flow.install_path:
35+
bolt_resp = await oauth_flow.handle_installation(
36+
await self._to_bolt_request(req)
37+
)
38+
await self._write_response(bolt_resp, resp)
39+
return
40+
elif req.path == oauth_flow.redirect_uri_path:
41+
bolt_resp = await oauth_flow.handle_callback(
42+
await self._to_bolt_request(req)
43+
)
44+
await self._write_response(bolt_resp, resp)
45+
return
46+
47+
resp.status = "404"
48+
resp.body = "The page is not found..."
49+
50+
async def on_post(self, req: Request, resp: Response):
51+
bolt_req = await self._to_bolt_request(req)
52+
bolt_resp = await self.app.async_dispatch(bolt_req)
53+
await self._write_response(bolt_resp, resp)
54+
55+
async def _to_bolt_request(self, req: Request) -> AsyncBoltRequest:
56+
return AsyncBoltRequest(
57+
body=(await req.stream.read(req.content_length or 0)).decode("utf-8"),
58+
query=req.query_string,
59+
headers={k.lower(): v for k, v in req.headers.items()},
60+
)
61+
62+
async def _write_response(self, bolt_resp: BoltResponse, resp: Response):
63+
resp.text = bolt_resp.body
64+
status = HTTPStatus(bolt_resp.status)
65+
resp.status = str(f"{status.value} {status.phrase}")
66+
resp.set_headers(bolt_resp.first_headers_without_set_cookie())
67+
for cookie in bolt_resp.cookies():
68+
for name, c in cookie.items():
69+
expire_value = c.get("expires")
70+
expire = (
71+
datetime.strptime(expire_value, "%a, %d %b %Y %H:%M:%S %Z")
72+
if expire_value
73+
else None
74+
)
75+
resp.set_cookie(
76+
name=name,
77+
value=c.value,
78+
expires=expire,
79+
max_age=c.get("max-age"),
80+
domain=c.get("domain"),
81+
path=c.get("path"),
82+
secure=True,
83+
http_only=True,
84+
)

0 commit comments

Comments
 (0)