diff --git a/examples/wsgi/app.py b/examples/wsgi/app.py new file mode 100644 index 00000000..d994ffbf --- /dev/null +++ b/examples/wsgi/app.py @@ -0,0 +1,19 @@ +from slack_bolt import App +from slack_bolt.adapter.wsgi import SlackRequestHandler + +app = App() + + +@app.event("app_mention") +def handle_app_mentions(body, say, logger): + logger.info(body) + say("What's up?") + + +api = SlackRequestHandler(app) + +# pip install -r requirements.txt +# export SLACK_SIGNING_SECRET=*** +# export SLACK_BOT_TOKEN=xoxb-*** +# gunicorn app:api -b 0.0.0.0:3000 --log-level debug +# ngrok http 3000 diff --git a/examples/wsgi/oauth_app.py b/examples/wsgi/oauth_app.py new file mode 100644 index 00000000..bdb844fd --- /dev/null +++ b/examples/wsgi/oauth_app.py @@ -0,0 +1,23 @@ +from slack_bolt import App +from slack_bolt.adapter.wsgi import SlackRequestHandler + +app = App() + + +@app.event("app_mention") +def handle_app_mentions(body, say, logger): + logger.info(body) + say("What's up?") + + +api = SlackRequestHandler(app) + +# 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 + +# gunicorn oauth_app:api -b 0.0.0.0:3000 --log-level debug diff --git a/examples/wsgi/requirements.txt b/examples/wsgi/requirements.txt new file mode 100644 index 00000000..5c3ac575 --- /dev/null +++ b/examples/wsgi/requirements.txt @@ -0,0 +1 @@ +gunicorn<23 diff --git a/slack_bolt/adapter/wsgi/__init__.py b/slack_bolt/adapter/wsgi/__init__.py new file mode 100644 index 00000000..bf7cf78a --- /dev/null +++ b/slack_bolt/adapter/wsgi/__init__.py @@ -0,0 +1,3 @@ +from .handler import SlackRequestHandler + +__all__ = ["SlackRequestHandler"] diff --git a/slack_bolt/adapter/wsgi/handler.py b/slack_bolt/adapter/wsgi/handler.py new file mode 100644 index 00000000..55537e46 --- /dev/null +++ b/slack_bolt/adapter/wsgi/handler.py @@ -0,0 +1,85 @@ +from typing import Any, Callable, Dict, Iterable, List, Tuple + +from slack_bolt import App +from slack_bolt.adapter.wsgi.http_request import WsgiHttpRequest +from slack_bolt.adapter.wsgi.http_response import WsgiHttpResponse +from slack_bolt.oauth.oauth_flow import OAuthFlow +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + + +class SlackRequestHandler: + def __init__(self, app: App, path: str = "/slack/events"): + """Setup Bolt as a WSGI web framework, this will make your application compatible with WSGI web servers. + This can be used for production deployments. + + With the default settings, `http://localhost:3000/slack/events` + Run Bolt with [gunicorn](https://gunicorn.org/) + + # Python + app = App() + + api = SlackRequestHandler(app) + + # bash + export SLACK_SIGNING_SECRET=*** + + export SLACK_BOT_TOKEN=xoxb-*** + + gunicorn app:api -b 0.0.0.0:3000 --log-level debug + + Args: + app: Your bolt application + path: The path to handle request from Slack (Default: `/slack/events`) + """ + self.path = path + self.app = app + + def dispatch(self, request: WsgiHttpRequest) -> BoltResponse: + return self.app.dispatch( + BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers()) + ) + + def handle_installation(self, request: WsgiHttpRequest) -> BoltResponse: + oauth_flow: OAuthFlow = self.app.oauth_flow + return oauth_flow.handle_installation( + BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers()) + ) + + def handle_callback(self, request: WsgiHttpRequest) -> BoltResponse: + oauth_flow: OAuthFlow = self.app.oauth_flow + return oauth_flow.handle_callback( + BoltRequest(body=request.get_body(), query=request.query_string, headers=request.get_headers()) + ) + + def _get_http_response(self, request: WsgiHttpRequest) -> WsgiHttpResponse: + if request.method == "GET": + if self.app.oauth_flow is not None: + if request.path == self.app.oauth_flow.install_path: + bolt_response: BoltResponse = self.handle_installation(request) + return WsgiHttpResponse( + status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body + ) + if request.path == self.app.oauth_flow.redirect_uri_path: + bolt_response: BoltResponse = self.handle_callback(request) + return WsgiHttpResponse( + status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body + ) + if request.method == "POST" and request.path == self.path: + bolt_response: BoltResponse = self.dispatch(request) + return WsgiHttpResponse(status=bolt_response.status, headers=bolt_response.headers, body=bolt_response.body) + return WsgiHttpResponse(status=404, headers={"content-type": ["text/plain;charset=utf-8"]}, body="Not Found") + + def __call__( + self, + environ: Dict[str, Any], + start_response: Callable[[str, List[Tuple[str, str]]], None], + ) -> Iterable[bytes]: + request = WsgiHttpRequest(environ) + if "HTTP" in request.protocol: + response: WsgiHttpResponse = self._get_http_response( + request=request, + ) + start_response(response.status, response.get_headers()) + return response.get_body() + raise TypeError(f"Unsupported SERVER_PROTOCOL: {request.protocol}") diff --git a/slack_bolt/adapter/wsgi/http_request.py b/slack_bolt/adapter/wsgi/http_request.py new file mode 100644 index 00000000..e410f4cc --- /dev/null +++ b/slack_bolt/adapter/wsgi/http_request.py @@ -0,0 +1,37 @@ +from typing import Any, Dict + +from .internals import ENCODING + + +class WsgiHttpRequest: + """This Class uses the PEP 3333 standard to extract request information + from the WSGI web server running the application + + PEP 3333: https://peps.python.org/pep-3333/ + """ + + __slots__ = ("method", "path", "query_string", "protocol", "environ") + + def __init__(self, environ: Dict[str, Any]): + self.method: str = environ.get("REQUEST_METHOD", "GET") + self.path: str = environ.get("PATH_INFO", "") + self.query_string: str = environ.get("QUERY_STRING", "") + self.protocol: str = environ.get("SERVER_PROTOCOL", "") + self.environ = environ + + def get_headers(self) -> Dict[str, str]: + headers = {} + for key, value in self.environ.items(): + if key in {"CONTENT_LENGTH", "CONTENT_TYPE"}: + name = key.lower().replace("_", "-") + headers[name] = value + if key.startswith("HTTP_"): + name = key[len("HTTP_"):].lower().replace("_", "-") # fmt: skip + headers[name] = value + return headers + + def get_body(self) -> str: + if "wsgi.input" not in self.environ: + return "" + content_length = int(self.environ.get("CONTENT_LENGTH", 0)) + return self.environ["wsgi.input"].read(content_length).decode(ENCODING) diff --git a/slack_bolt/adapter/wsgi/http_response.py b/slack_bolt/adapter/wsgi/http_response.py new file mode 100644 index 00000000..1ad32e67 --- /dev/null +++ b/slack_bolt/adapter/wsgi/http_response.py @@ -0,0 +1,33 @@ +from http import HTTPStatus +from typing import Dict, Iterable, List, Sequence, Tuple + +from .internals import ENCODING + + +class WsgiHttpResponse: + """This Class uses the PEP 3333 standard to adapt bolt response information + for the WSGI web server running the application + + PEP 3333: https://peps.python.org/pep-3333/ + """ + + __slots__ = ("status", "_headers", "_body") + + def __init__(self, status: int, headers: Dict[str, Sequence[str]] = {}, body: str = ""): + _status = HTTPStatus(status) + self.status = f"{_status.value} {_status.phrase}" + self._headers = headers + self._body = bytes(body, ENCODING) + + def get_headers(self) -> List[Tuple[str, str]]: + headers: List[Tuple[str, str]] = [] + for key, value in self._headers.items(): + if key.lower() == "content-length": + continue + headers.append((key, value[0])) + + headers.append(("content-length", str(len(self._body)))) + return headers + + def get_body(self) -> Iterable[bytes]: + return [self._body] diff --git a/slack_bolt/adapter/wsgi/internals.py b/slack_bolt/adapter/wsgi/internals.py new file mode 100644 index 00000000..cda3e876 --- /dev/null +++ b/slack_bolt/adapter/wsgi/internals.py @@ -0,0 +1 @@ +ENCODING = "utf-8" # The content encoding for Slack requests/responses is always utf-8 diff --git a/tests/adapter_tests/wsgi/__init__.py b/tests/adapter_tests/wsgi/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/adapter_tests/wsgi/test_wsgi_http.py b/tests/adapter_tests/wsgi/test_wsgi_http.py new file mode 100644 index 00000000..63ac6262 --- /dev/null +++ b/tests/adapter_tests/wsgi/test_wsgi_http.py @@ -0,0 +1,230 @@ +import json +from time import time +from urllib.parse import quote + +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web import WebClient + +from slack_bolt.adapter.wsgi import SlackRequestHandler +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from tests.mock_web_api_server import assert_auth_test_count, cleanup_mock_web_api_server, setup_mock_web_api_server +from tests.mock_wsgi_server import WsgiTestServer +from tests.utils import remove_os_env_temporarily, restore_os_env + + +class TestWsgiHttp: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + signature_verifier = SignatureVerifier(signing_secret) + web_client = WebClient( + 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_raw_headers(self, timestamp: str, body: str = ""): + content_type = "application/json" if body.startswith("{") else "application/x-www-form-urlencoded" + return { + "host": "123.123.123", + "user-agent": "some slack thing", + "content-length": str(len(body)), + "accept": "application/json,*/*", + "accept-encoding": "gzip,deflate", + "content-type": content_type, + "x-forwarded-for": "123.123.123", + "x-forwarded-proto": "https", + "x-slack-request-timestamp": timestamp, + "x-slack-signature": self.generate_signature(body, timestamp), + } + + def test_commands(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def command_handler(ack): + ack() + + app.command("/hello-world")(command_handler) + + body = ( + "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" + ) + + headers = self.build_raw_headers(str(int(time())), body) + + wsgi_server = WsgiTestServer(SlackRequestHandler(app)) + + response = wsgi_server.http(method="POST", headers=headers, body=body) + + assert response.status == "200 OK" + assert response.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_events(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def event_handler(): + pass + + app.event("app_mention")(event_handler) + + body = 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"], + } + ) + headers = self.build_raw_headers(str(int(time())), body) + + wsgi_server = WsgiTestServer(SlackRequestHandler(app)) + response = wsgi_server.http(method="POST", headers=headers, body=body) + + assert response.status == "200 OK" + assert response.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_shortcuts(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + def shortcut_handler(ack): + ack() + + app.shortcut("test-shortcut")(shortcut_handler) + + body_data = { + "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", + } + + body = f"payload={quote(json.dumps(body_data))}" + headers = self.build_raw_headers(str(int(time())), body) + + wsgi_server = WsgiTestServer(SlackRequestHandler(app)) + response = wsgi_server.http(method="POST", headers=headers, body=body) + + assert response.status == "200 OK" + assert response.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_oauth(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + oauth_settings=OAuthSettings( + client_id="111.111", + client_secret="xxx", + scopes=["chat:write", "commands"], + ), + ) + + headers = self.build_raw_headers(str(int(time()))) + + wsgi_server = WsgiTestServer(SlackRequestHandler(app)) + response = wsgi_server.http(method="GET", headers=headers, path="/slack/install") + + assert response.status == "200 OK" + assert response.headers.get("content-type") == "text/html; charset=utf-8" + assert "https://slack.com/oauth/v2/authorize?state=" in response.body + + def test_url_verification(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + body_data = { + "token": "Jhj5dZrVaK7ZwHHjRyZWjbDl", + "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P", + "type": "url_verification", + } + + body = f"payload={quote(json.dumps(body_data))}" + headers = self.build_raw_headers(str(int(time())), body) + + wsgi_server = WsgiTestServer(SlackRequestHandler(app)) + response = wsgi_server.http( + method="POST", + headers=headers, + body=body, + ) + + assert response.status == "200 OK" + assert response.headers.get("content-type") == "application/json;charset=utf-8" + assert_auth_test_count(self, 1) + + def test_unsupported_method(self): + app = App( + client=self.web_client, + signing_secret=self.signing_secret, + ) + + body = "" + headers = self.build_raw_headers(str(int(time())), "") + + wsgi_server = WsgiTestServer(SlackRequestHandler(app)) + response = wsgi_server.http(method="PUT", headers=headers, body=body) + + assert response.status == "404 Not Found" + assert response.headers.get("content-type") == "text/plain;charset=utf-8" + assert_auth_test_count(self, 1) diff --git a/tests/mock_wsgi_server.py b/tests/mock_wsgi_server.py new file mode 100644 index 00000000..a389a898 --- /dev/null +++ b/tests/mock_wsgi_server.py @@ -0,0 +1,116 @@ +from typing import Dict, Iterable, Optional, Tuple + +from slack_bolt.adapter.wsgi import SlackRequestHandler + +ENCODING = "utf-8" + + +class WsgiTestServerResponse: + def __init__(self): + self.status: Optional[str] = None + self._headers: Iterable[Tuple[str, str]] = [] + self._body: Iterable[bytes] = [] + + @property + def headers(self) -> Dict[str, str]: + return {header[0]: header[1] for header in self._headers} + + @property + def body(self, length: int = 0) -> str: + return "".join([chunk.decode(ENCODING) for chunk in self._body[length:]]) + + +class MockReadable: + def __init__(self, body: str): + self.body = body + self._body = bytes(body, ENCODING) + + def get_content_length(self) -> int: + return len(self._body) + + def read(self, size: int) -> bytes: + if size < 0: + raise ValueError("Size must be positive.") + if size == 0: + return b"" + # The body can only be read once + _body = self._body[:size] + self._body = b"" + return _body + + +class WsgiTestServer: + def __init__( + self, + wsgi_app: SlackRequestHandler, + root_path: str = "", + version: Tuple[int, int] = (1, 0), + multithread: bool = False, + multiprocess: bool = False, + run_once: bool = False, + input_terminated: bool = True, + server_software: bool = "mock/0.0.0", + url_scheme: str = "https", + remote_addr: str = "127.0.0.1", + remote_port: str = "63263", + ): + self.root_path = root_path + self.wsgi_app = wsgi_app + self.environ = { + "wsgi.version": version, + "wsgi.multithread": multithread, + "wsgi.multiprocess": multiprocess, + "wsgi.run_once": run_once, + "wsgi.input_terminated": input_terminated, + "SERVER_SOFTWARE": server_software, + "wsgi.url_scheme": url_scheme, + "REMOTE_ADDR": remote_addr, + "REMOTE_PORT": remote_port, + } + + def http( + self, + method: str, + headers: Dict[str, str], + body: Optional[str] = None, + path: str = "/slack/events", + query_string: str = "", + server_protocol: str = "HTTP/1.1", + server_name: str = "0.0.0.0", + server_port: str = "3000", + script_name: str = "", + ) -> WsgiTestServerResponse: + environ = dict( + self.environ, + **{ + "REQUEST_METHOD": method, + "PATH_INFO": f"{self.root_path}{path}", + "QUERY_STRING": query_string, + "RAW_URI": f"{self.root_path}{path}?{query_string}", + "SERVER_PROTOCOL": server_protocol, + "SERVER_NAME": server_name, + "SERVER_PORT": server_port, + "SCRIPT_NAME": script_name, + }, + ) + for key, value in headers.items(): + header_key = key.upper().replace("-", "_") + if header_key in {"CONTENT_LENGTH", "CONTENT_TYPE"}: + environ[header_key] = value + else: + environ[f"HTTP_{header_key}"] = value + + if body is not None: + environ["wsgi.input"] = MockReadable(body) + if "CONTENT_LENGTH" not in environ: + environ["CONTENT_LENGTH"] = str(environ["wsgi.input"].get_content_length()) + + response = WsgiTestServerResponse() + + def start_response(status, headers): + response.status = status + response._headers = headers + + response._body = self.wsgi_app(environ=environ, start_response=start_response) + + return response