diff --git a/examples/quart/async_app.py b/examples/quart/async_app.py new file mode 100644 index 000000000..3bdf065ea --- /dev/null +++ b/examples/quart/async_app.py @@ -0,0 +1,33 @@ +import os +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.quart import AsyncSlackRequestHandler + +app = AsyncApp() +app_handler = AsyncSlackRequestHandler(app) + + +@app.event("app_mention") +async def handle_app_mentions(body, say, logger): + logger.info(body) + await say("What's up?") + + +from quart import Quart, request + +api = Quart(__name__) + + +@api.post("/slack/events") +async def endpoint(): + return await app_handler.handle(request) + + +if __name__ == "__main__": + api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) + + +# Requires Python 3.9+ +# pip install -r requirements.txt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# hypercorn async_app:api --reload --bind 0.0.0.0:3000 diff --git a/examples/quart/async_oauth_app.py b/examples/quart/async_oauth_app.py new file mode 100644 index 000000000..86a35658b --- /dev/null +++ b/examples/quart/async_oauth_app.py @@ -0,0 +1,48 @@ +import os +from slack_bolt.async_app import AsyncApp +from slack_bolt.adapter.quart import AsyncSlackRequestHandler + +app = AsyncApp() +app_handler = AsyncSlackRequestHandler(app) + + +@app.event("app_mention") +async def handle_app_mentions(body, say, logger): + logger.info(body) + await say("What's up?") + + +from quart import Quart, request + +api = Quart(__name__) + + +@api.post("/slack/events") +async def endpoint(): + return await app_handler.handle(request) + + +@api.get("/slack/install") +async def install(): + return await app_handler.handle(request) + + +@api.get("/slack/oauth_redirect") +async def oauth_redirect(): + return await app_handler.handle(request) + + +if __name__ == "__main__": + api.run(host="0.0.0.0", port=int(os.environ.get("PORT", 3000))) + + +# Requires Python 3.9+ +# pip install -r requirements.txt + +# # -- OAuth flow -- # +# export SLACK_SIGNING_SECRET=*** +# export SLACK_CLIENT_ID=111.111 +# export SLACK_CLIENT_SECRET=*** +# export SLACK_SCOPES=app_mentions:read,channels:history,im:history,chat:write + +# hypercorn async_oauth_app:api --reload --bind 0.0.0.0:3000 diff --git a/examples/quart/requirements.txt b/examples/quart/requirements.txt new file mode 100644 index 000000000..8c610a67c --- /dev/null +++ b/examples/quart/requirements.txt @@ -0,0 +1,2 @@ +# Requires Python 3.9+ +quart>=0.20,<1 diff --git a/requirements/adapter_dev.txt b/requirements/adapter_dev.txt index ea924f5bb..a82de871f 100644 --- a/requirements/adapter_dev.txt +++ b/requirements/adapter_dev.txt @@ -13,6 +13,7 @@ falcon>=2,<4; python_version<"3.9" falcon>=4.2.0,<5; python_version>="3.9" fastapi>=0.70.0,<1 Flask>=1,<4 +quart>=0.20,<1; python_version>="3.9" Werkzeug>=2,<3; python_version<"3.9" Werkzeug>=3.1.8,<4; python_version>="3.9" pyramid>=1,<3 diff --git a/slack_bolt/adapter/quart/__init__.py b/slack_bolt/adapter/quart/__init__.py new file mode 100644 index 000000000..3805e26ed --- /dev/null +++ b/slack_bolt/adapter/quart/__init__.py @@ -0,0 +1,5 @@ +from .async_handler import AsyncSlackRequestHandler + +__all__ = [ + "AsyncSlackRequestHandler", +] diff --git a/slack_bolt/adapter/quart/async_handler.py b/slack_bolt/adapter/quart/async_handler.py new file mode 100644 index 000000000..ee08499e7 --- /dev/null +++ b/slack_bolt/adapter/quart/async_handler.py @@ -0,0 +1,73 @@ +from typing import Any, Dict, Optional, cast + +from quart import Request, Response, make_response + +from slack_bolt import BoltResponse +from slack_bolt.async_app import AsyncApp, AsyncBoltRequest +from slack_bolt.oauth.async_oauth_flow import AsyncOAuthFlow + + +async def to_async_bolt_request( + req: Request, + addition_context_properties: Optional[Dict[str, Any]] = None, +) -> AsyncBoltRequest: + request = AsyncBoltRequest( + body=cast(str, await req.get_data(as_text=True)), + query=req.query_string.decode("utf-8"), + headers=req.headers, # type: ignore[arg-type] + ) + + if addition_context_properties is not None: + for k, v in addition_context_properties.items(): + request.context[k] = v + + return request + + +async def to_quart_response(bolt_resp: BoltResponse) -> Response: + resp = cast(Response, await make_response(bolt_resp.body, bolt_resp.status)) + for k, values in bolt_resp.headers.items(): + if k == "set-cookie": + continue + if k.lower() == "content-type" and resp.headers.get("content-type") is not None: + resp.headers.pop("content-type") + for v in values: + resp.headers.add_header(k, v) + + for cookie in bolt_resp.cookies(): + for name, c in cookie.items(): + max_age = int(c["max-age"]) if c.get("max-age") else None + resp.set_cookie( + key=name, + value=c.value, + max_age=max_age, + expires=c.get("expires") or None, + path=c.get("path") or None, + domain=c.get("domain") or None, + secure=True, + httponly=True, + ) + + return resp + + +class AsyncSlackRequestHandler: + def __init__(self, app: AsyncApp): + self.app = app + + async def handle(self, req: Request, addition_context_properties: Optional[Dict[str, Any]] = None) -> Response: + if req.method == "POST": + bolt_resp = await self.app.async_dispatch(await to_async_bolt_request(req, addition_context_properties)) + return await to_quart_response(bolt_resp) + + if req.method == "GET" and self.app.oauth_flow is not None: + oauth_flow: AsyncOAuthFlow = self.app.oauth_flow + bolt_req = await to_async_bolt_request(req, addition_context_properties) + if req.path == oauth_flow.install_path: + bolt_resp = await oauth_flow.handle_installation(bolt_req) + return await to_quart_response(bolt_resp) + if req.path == oauth_flow.redirect_uri_path: + bolt_resp = await oauth_flow.handle_callback(bolt_req) + return await to_quart_response(bolt_resp) + + return cast(Response, await make_response("Not Found", 404)) diff --git a/tests/adapter_tests_async/test_async_quart.py b/tests/adapter_tests_async/test_async_quart.py new file mode 100644 index 000000000..c6d15be2d --- /dev/null +++ b/tests/adapter_tests_async/test_async_quart.py @@ -0,0 +1,343 @@ +import json +import sys +from time import time +from urllib.parse import quote + +import pytest +from slack_sdk.oauth.state_store.async_state_store import AsyncOAuthStateStore +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt import BoltResponse +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.oauth.async_oauth_settings import AsyncOAuthSettings +from tests.mock_web_api_server import ( + assert_auth_test_count, + cleanup_mock_web_api_server, + setup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env + +pytestmark = pytest.mark.skipif(sys.version_info < (3, 9), reason="Quart requires Python 3.9+") + +if sys.version_info >= (3, 9): + from slack_bolt.adapter.quart.async_handler import AsyncSlackRequestHandler, to_quart_response + from quart import Quart, request + + +class TestAsyncStateStore(AsyncOAuthStateStore): + async def async_issue(self, *args, **kwargs) -> str: + return "uuid4-value" + + async def async_consume(self, state: str) -> bool: + return state == "uuid4-value" + + +class TestAsyncQuart: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def generate_signature(self, body: str, timestamp: str): + return self.signature_verifier.generate_signature( + body=body, + timestamp=timestamp, + ) + + def build_headers(self, timestamp: str, body: str): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "content-type": content_type, + "x-slack-signature": self.generate_signature(body, timestamp), + "x-slack-request-timestamp": timestamp, + } + + def build_app(self, oauth_settings=None): + return AsyncApp( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=oauth_settings, + ) + + def build_client(self, app, path: str = "/slack/events", method: str = "POST", addition_context_properties=None): + api = Quart(__name__) + app_handler = AsyncSlackRequestHandler(app) + + async def endpoint(): + return await app_handler.handle(request, addition_context_properties) + + api.add_url_rule(path, "endpoint", endpoint, methods=[method]) + return api.test_client() + + def build_event_body(self) -> str: + return json.dumps( + { + "token": "verification_token", + "team_id": "T111", + "enterprise_id": "E111", + "api_app_id": "A111", + "event": { + "client_msg_id": "9cbd4c5b-7ddf-4ede-b479-ad21fca66d63", + "type": "app_mention", + "text": "<@W111> Hi there!", + "user": "W222", + "ts": "1595926230.009600", + "team": "T111", + "channel": "C111", + "event_ts": "1595926230.009600", + }, + "type": "event_callback", + "event_id": "Ev111", + "event_time": 1595926230, + "authed_users": ["W111"], + } + ) + + @pytest.mark.asyncio + async def test_events(self): + app = self.build_app() + + async def event_handler(): + pass + + app.event("app_mention")(event_handler) + + timestamp, body = str(int(time())), self.build_event_body() + client = self.build_client(app) + response = await client.post( + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + @pytest.mark.asyncio + async def test_events_with_additional_context_properties(self): + app = self.build_app() + observed_context = {} + + async def event_handler(context): + observed_context["custom_value"] = context["custom_value"] + + app.event("app_mention")(event_handler) + + timestamp, body = str(int(time())), self.build_event_body() + client = self.build_client(app, addition_context_properties={"custom_value": "quart"}) + response = await client.post( + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert observed_context["custom_value"] == "quart" + assert_auth_test_count(self, 1) + + @pytest.mark.asyncio + async def test_shortcuts(self): + app = self.build_app() + + async def shortcut_handler(ack): + await ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + input = { + "type": "shortcut", + "token": "verification_token", + "action_ts": "111.111", + "team": { + "id": "T111", + "domain": "workspace-domain", + "enterprise_id": "E111", + "enterprise_name": "Org Name", + }, + "user": {"id": "W111", "username": "primary-owner", "team_id": "T111"}, + "callback_id": "test-shortcut", + "trigger_id": "111.111.xxxxxx", + } + + timestamp, body = str(int(time())), f"payload={quote(json.dumps(input))}" + + client = self.build_client(app) + response = await client.post( + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + @pytest.mark.asyncio + async def test_commands(self): + app = self.build_app() + + async def command_handler(ack): + await ack() + + app.command("/hello-world")(command_handler) + + input = ( + "token=verification_token" + "&team_id=T111" + "&team_domain=test-domain" + "&channel_id=C111" + "&channel_name=random" + "&user_id=W111" + "&user_name=primary-owner" + "&command=%2Fhello-world" + "&text=Hi" + "&enterprise_id=E111" + "&enterprise_name=Org+Name" + "&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxxxx" + "&trigger_id=111.111.xxx" + ) + timestamp, body = str(int(time())), input + + client = self.build_client(app) + response = await client.post( + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert_auth_test_count(self, 1) + + @pytest.mark.asyncio + async def test_oauth(self): + app = self.build_app( + oauth_settings=AsyncOAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + + client = self.build_client(app, path="/slack/install", method="GET") + response = await client.get("/slack/install") + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert response.headers.get("set-cookie") is not None + assert "https://slack.com/oauth/v2/authorize?state=" in await response.get_data(as_text=True) + + @pytest.mark.asyncio + async def test_oauth_callback(self): + app = self.build_app( + oauth_settings=AsyncOAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + state_store=TestAsyncStateStore(), + ), + ) + + client = self.build_client(app, path="/slack/oauth_redirect", method="GET") + response = await client.get( + "/slack/oauth_redirect?code=1234567890&state=uuid4-value", + headers={"Cookie": "slack-app-oauth-state=uuid4-value"}, + ) + assert response.status_code == 200 + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert await response.get_data(as_text=True) is not None + + @pytest.mark.asyncio + async def test_url_verification(self): + app = self.build_app() + + input = { + "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl", + "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + "type": "url_verification", + } + + timestamp, body = str(int(time())), json.dumps(input) + + client = self.build_client(app) + response = await client.post( + "/slack/events", + data=body, + headers=self.build_headers(timestamp, body), + ) + assert response.status_code == 200 + assert response.headers.get("content-type") == "application/json;charset=utf-8" + assert json.loads(await response.get_data(as_text=True)) == {"challenge": input["challenge"]} + assert_auth_test_count(self, 0) + + @pytest.mark.asyncio + async def test_to_quart_response_preserves_multi_value_headers_and_content_type(self): + api = Quart(__name__) + async with api.app_context(): + response = await to_quart_response( + BoltResponse( + status=201, + body="created", + headers={ + "content-type": "application/custom", + "x-bolt-test": ["one", "two"], + }, + ) + ) + + assert response.status_code == 201 + assert await response.get_data(as_text=True) == "created" + assert response.headers.get("content-type") == "application/custom" + assert response.headers.getlist("x-bolt-test") == ["one", "two"] + + @pytest.mark.asyncio + async def test_to_quart_response_preserves_cookie_attributes(self): + api = Quart(__name__) + async with api.app_context(): + response = await to_quart_response( + BoltResponse( + status=200, + body="", + headers={ + "set-cookie": [ + "session=abc; Max-Age=60; Path=/install; Domain=example.com", + "bare=xyz", + ], + }, + ) + ) + + set_cookie_headers = response.headers.getlist("set-cookie") + assert len(set_cookie_headers) == 2 + + session_cookie = set_cookie_headers[0] + assert "session=abc" in session_cookie + assert "Domain=example.com" in session_cookie + assert "Max-Age=60" in session_cookie + assert "Path=/install" in session_cookie + assert "Secure" in session_cookie + assert "HttpOnly" in session_cookie + assert "Expires=;" not in session_cookie + + bare_cookie = set_cookie_headers[1] + assert "bare=xyz" in bare_cookie + assert "Secure" in bare_cookie + assert "HttpOnly" in bare_cookie + assert "Domain=" not in bare_cookie + assert "Expires=" not in bare_cookie + assert "Max-Age=" not in bare_cookie + assert "Path=" not in bare_cookie + + @pytest.mark.asyncio + async def test_not_found(self): + app = self.build_app() + client = self.build_client(app, path="/slack/unknown", method="GET") + response = await client.get("/slack/unknown") + assert response.status_code == 404 + assert "Not Found" == await response.get_data(as_text=True)