diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 90ffbc6b7..351f9257f 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -25,7 +25,7 @@ jobs: run: | python setup.py install pip install -U pip - pip install "pytest>=5,<6" "pytest-cov>=2,<3" + pip install "pytest>=5,<6" "pytest-cov>=2,<3" "flask_sockets>0.2,<1" - name: Run tests without aiohttp run: | pytest tests/slack_bolt/ diff --git a/codecov.yml b/codecov.yml index b24c2afb1..5568e5e6b 100644 --- a/codecov.yml +++ b/codecov.yml @@ -2,7 +2,7 @@ coverage: status: project: default: - threshold: 0.3% + threshold: 2.0% patch: default: target: 50% diff --git a/docs/_basic/socket_mode.md b/docs/_basic/socket_mode.md new file mode 100644 index 000000000..323a5e6be --- /dev/null +++ b/docs/_basic/socket_mode.md @@ -0,0 +1,55 @@ +--- +title: Using Socket Mode +lang: en +slug: socket-mode +order: 16 +--- + +
+With the introduction of [Socket Mode](https://api.slack.com/socket-mode), Bolt for Python introduced support in version `1.2.0`. With Socket Mode, instead of creating a server with endpoints that Slack sends payloads too, the app will instead connect to Slack via a WebSocket connection and receive data from Slack over the socket connection. Make sure to enable Socket Mode in your app configuration settings. + +To use the Socket Mode, add `SLACK_APP_TOKEN` as an environment variable. You can get your App Token in your app configuration settings under the **Basic Information** section. +
+ +```python +import os +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# Install the Slack app and get xoxb- token in advance +app = App(token=os.environ["SLACK_BOT_TOKEN"]) + +if __name__ == "__main__": + # export SLACK_APP_TOKEN=xapp-*** + # export SLACK_BOT_TOKEN=xoxb-*** + SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() +``` + +While we recommend using [the built-in Socket Mode adapter](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter/socket_mode/builtin), there are a few other 3rd party library based implementations. Here is the list of available adapters. + +|PyPI Project|Bolt Adapter| +|-|-| +|[slack_sdk](https://pypi.org/project/slack-sdk/)|[slack_bolt.adapter.socket_mode](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter/socket_mode/builtin)| +|[websocket_client](https://pypi.org/project/websocket_client/)|[slack_bolt.adapter.socket_mode.websocket_client](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter/socket_mode/websocket_client)| +|[aiohttp](https://pypi.org/project/aiohttp/) (asyncio-based)|[slack_bolt.adapter.socket_mode.aiohttp](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter/socket_mode/aiohttp)| +|[websockets](https://pypi.org/project/websockets/) (asyncio-based)|[slack_bolt.adapter.socket_mode.websockets](https://github.com/slackapi/bolt-python/tree/main/slack_bolt/adapter/socket_mode/websockets)| + +To use the asyncio-based adapters such as aiohttp, your app needs to be compatible with asyncio's async/await programming model. `AsyncSocketModeHandler` is available for running `AsyncApp` and its async middleware and listeners. + +```python +from slack_bolt.app.async_app import AsyncApp +# The default is the aiohttp based implementation +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + +app = AsyncApp(token=os.environ["SLACK_BOT_TOKEN"]) + +async def main(): + handler = AsyncSocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + await handler.start_async() + +if __name__ == "__main__": + import asyncio + asyncio.run(main()) +``` + +To learn how to use `AsyncApp`, checkout the [Using Async](https://slack.dev/bolt-python/concepts#async) document and relevant [examples](https://github.com/slackapi/bolt-python/tree/main/examples). diff --git a/examples/socket_mode.py b/examples/socket_mode.py new file mode 100644 index 000000000..e4b2f10ba --- /dev/null +++ b/examples/socket_mode.py @@ -0,0 +1,102 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + +sys.path.insert(1, "..") +# ------------------------------------------------ + +import logging + +logging.basicConfig(level=logging.DEBUG) + +import os + +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler + +# Install the Slack app and get xoxb- token in advance +app = App(token=os.environ["SLACK_BOT_TOKEN"]) + + +@app.command("/hello-socket-mode") +def hello_command(ack, body): + user_id = body["user_id"] + ack(f"Hi <@{user_id}>!") + + +@app.event("app_mention") +def event_test(event, say): + say(f"Hi there, <@{event['user']}>!") + + +def ack_shortcut(ack): + ack() + + +def open_modal(body, client): + client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "socket_modal_submission", + "submit": {"type": "plain_text", "text": "Submit",}, + "close": {"type": "plain_text", "text": "Cancel",}, + "title": {"type": "plain_text", "text": "Socket Modal",}, + "blocks": [ + { + "type": "input", + "block_id": "q1", + "label": {"type": "plain_text", "text": "Write anything here!",}, + "element": {"action_id": "feedback", "type": "plain_text_input",}, + }, + { + "type": "input", + "block_id": "q2", + "label": { + "type": "plain_text", + "text": "Can you tell us your favorites?", + }, + "element": { + "type": "external_select", + "action_id": "favorite-animal", + "min_query_length": 0, + "placeholder": { + "type": "plain_text", + "text": "Select your favorites", + }, + }, + }, + ], + }, + ) + + +app.shortcut("socket-mode")(ack=ack_shortcut, lazy=[open_modal]) + + +all_options = [ + {"text": {"type": "plain_text", "text": ":cat: Cat"}, "value": "cat",}, + {"text": {"type": "plain_text", "text": ":dog: Dog"}, "value": "dog",}, + {"text": {"type": "plain_text", "text": ":bear: Bear"}, "value": "bear",}, +] + + +@app.options("favorite-animal") +def external_data_source_handler(ack, body): + keyword = body.get("value") + if keyword is not None and len(keyword) > 0: + options = [o for o in all_options if keyword in o["text"]["text"]] + ack(options=options) + else: + ack(options=all_options) + + +@app.view("socket_modal_submission") +def submission(ack): + ack() + + +if __name__ == "__main__": + # export SLACK_APP_TOKEN=xapp-*** + # export SLACK_BOT_TOKEN=xoxb-*** + SocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]).start() diff --git a/examples/socket_mode_async.py b/examples/socket_mode_async.py new file mode 100644 index 000000000..99bb0ae69 --- /dev/null +++ b/examples/socket_mode_async.py @@ -0,0 +1,112 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + + +sys.path.insert(1, "..") +# ------------------------------------------------ + +import logging + +logging.basicConfig(level=logging.DEBUG) + +import os + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler + +# Install the Slack app and get xoxb- token in advance +app = AsyncApp(token=os.environ["SLACK_BOT_TOKEN"]) + + +@app.command("/hello-socket-mode") +async def hello_command(ack, body): + user_id = body["user_id"] + await ack(f"Hi <@{user_id}>!") + + +@app.event("app_mention") +async def event_test(event, say): + await say(f"Hi there, <@{event['user']}>!") + + +async def ack_shortcut(ack): + await ack() + + +async def open_modal(body, client): + await client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "socket_modal_submission", + "submit": {"type": "plain_text", "text": "Submit",}, + "close": {"type": "plain_text", "text": "Cancel",}, + "title": {"type": "plain_text", "text": "Socket Modal",}, + "blocks": [ + { + "type": "input", + "block_id": "q1", + "label": {"type": "plain_text", "text": "Write anything here!",}, + "element": {"action_id": "feedback", "type": "plain_text_input",}, + }, + { + "type": "input", + "block_id": "q2", + "label": { + "type": "plain_text", + "text": "Can you tell us your favorites?", + }, + "element": { + "type": "external_select", + "action_id": "favorite-animal", + "min_query_length": 0, + "placeholder": { + "type": "plain_text", + "text": "Select your favorites", + }, + }, + }, + ], + }, + ) + + +app.shortcut("socket-mode")(ack=ack_shortcut, lazy=[open_modal]) + + +all_options = [ + {"text": {"type": "plain_text", "text": ":cat: Cat"}, "value": "cat",}, + {"text": {"type": "plain_text", "text": ":dog: Dog"}, "value": "dog",}, + {"text": {"type": "plain_text", "text": ":bear: Bear"}, "value": "bear",}, +] + + +@app.options("favorite-animal") +async def external_data_source_handler(ack, body): + keyword = body.get("value") + if keyword is not None and len(keyword) > 0: + options = [o for o in all_options if keyword in o["text"]["text"]] + await ack(options=options) + else: + await ack(options=all_options) + + +@app.view("socket_modal_submission") +async def submission(ack): + await ack() + + +# export SLACK_APP_TOKEN=xapp-*** +# export SLACK_BOT_TOKEN=xoxb-*** + + +async def main(): + handler = AsyncSocketModeHandler(app, os.environ["SLACK_APP_TOKEN"]) + await handler.start_async() + + +if __name__ == "__main__": + import asyncio + + asyncio.run(main()) diff --git a/examples/socket_mode_oauth.py b/examples/socket_mode_oauth.py new file mode 100644 index 000000000..fd51b3150 --- /dev/null +++ b/examples/socket_mode_oauth.py @@ -0,0 +1,116 @@ +# ------------------------------------------------ +# instead of slack_bolt in requirements.txt +import sys + +sys.path.insert(1, "..") +# ------------------------------------------------ + +import logging + +logging.basicConfig(level=logging.DEBUG) + +import os +from slack_bolt.app import App +from slack_bolt.oauth.oauth_settings import OAuthSettings +from slack_bolt.adapter.socket_mode import SocketModeHandler + +app = App( + signing_secret=os.environ["SLACK_SIGNING_SECRET"], + oauth_settings=OAuthSettings( + client_id=os.environ["SLACK_CLIENT_ID"], + client_secret=os.environ["SLACK_CLIENT_SECRET"], + scopes=os.environ["SLACK_SCOPES"].split(","), + ), +) + + +@app.command("/hello-socket-mode") +def hello_command(ack, body): + user_id = body["user_id"] + ack(f"Hi <@{user_id}>!") + + +@app.event("app_mention") +def event_test(event, say): + say(f"Hi there, <@{event['user']}>!") + + +def ack_shortcut(ack): + ack() + + +def open_modal(body, client): + client.views_open( + trigger_id=body["trigger_id"], + view={ + "type": "modal", + "callback_id": "socket_modal_submission", + "submit": {"type": "plain_text", "text": "Submit",}, + "close": {"type": "plain_text", "text": "Cancel",}, + "title": {"type": "plain_text", "text": "Socket Modal",}, + "blocks": [ + { + "type": "input", + "block_id": "q1", + "label": {"type": "plain_text", "text": "Write anything here!",}, + "element": {"action_id": "feedback", "type": "plain_text_input",}, + }, + { + "type": "input", + "block_id": "q2", + "label": { + "type": "plain_text", + "text": "Can you tell us your favorites?", + }, + "element": { + "type": "external_select", + "action_id": "favorite-animal", + "min_query_length": 0, + "placeholder": { + "type": "plain_text", + "text": "Select your favorites", + }, + }, + }, + ], + }, + ) + + +app.shortcut("socket-mode")(ack=ack_shortcut, lazy=[open_modal]) + + +all_options = [ + {"text": {"type": "plain_text", "text": ":cat: Cat"}, "value": "cat",}, + {"text": {"type": "plain_text", "text": ":dog: Dog"}, "value": "dog",}, + {"text": {"type": "plain_text", "text": ":bear: Bear"}, "value": "bear",}, +] + + +@app.options("favorite-animal") +def external_data_source_handler(ack, body): + keyword = body.get("value") + if keyword is not None and len(keyword) > 0: + options = [o for o in all_options if keyword in o["text"]["text"]] + ack(options=options) + else: + ack(options=all_options) + + +@app.view("socket_modal_submission") +def submission(ack): + ack() + + +if __name__ == "__main__": + SocketModeHandler(app, os.environ.get("SLACK_APP_TOKEN")).connect() + app.start() + + # export SLACK_APP_TOKEN= + # export SLACK_SIGNING_SECRET= + # export SLACK_CLIENT_ID= + # export SLACK_CLIENT_SECRET= + # export SLACK_SCOPES= + # pip install .[optional] + # pip install slack_bolt + # python socket_mode_oauth.py diff --git a/scripts/uninstall_all.sh b/scripts/uninstall_all.sh index 188f97a41..1d3da265d 100755 --- a/scripts/uninstall_all.sh +++ b/scripts/uninstall_all.sh @@ -1,3 +1,4 @@ #!/bin/bash -pip freeze | grep -v "^-e" | xargs pip uninstall -y \ No newline at end of file +pip uninstall -y slack-bolt && \ + pip freeze | grep -v "^-e" | xargs pip uninstall -y diff --git a/setup.py b/setup.py index cb789759a..b2ba603d1 100755 --- a/setup.py +++ b/setup.py @@ -16,6 +16,7 @@ "pytest-cov>=2,<3", "pytest-asyncio<1", # for async "aiohttp>=3,<4", # for async + "Flask-Sockets>=0.2,<1", "black==20.8b1", ] @@ -33,7 +34,7 @@ exclude=["examples", "integration_tests", "tests", "tests.*",] ), include_package_data=True, # MANIFEST.in - install_requires=["slack_sdk>=3.1.1,<3.2",], + install_requires=["slack_sdk>=3.2.0rc2,<3.3",], setup_requires=["pytest-runner==5.2"], tests_require=test_dependencies, test_suite="tests", diff --git a/slack_bolt/adapter/socket_mode/__init__.py b/slack_bolt/adapter/socket_mode/__init__.py new file mode 100644 index 000000000..94223be4d --- /dev/null +++ b/slack_bolt/adapter/socket_mode/__init__.py @@ -0,0 +1,2 @@ +# Don't add async module imports here +from .builtin import SocketModeHandler # noqa diff --git a/slack_bolt/adapter/socket_mode/aiohttp/__init__.py b/slack_bolt/adapter/socket_mode/aiohttp/__init__.py new file mode 100644 index 000000000..0e3038c75 --- /dev/null +++ b/slack_bolt/adapter/socket_mode/aiohttp/__init__.py @@ -0,0 +1,80 @@ +import os +from logging import Logger +from time import time +from typing import Optional + +from slack_sdk.socket_mode.aiohttp import SocketModeClient +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt import App +from slack_bolt.adapter.socket_mode.async_base_handler import AsyncBaseSocketModeHandler +from slack_bolt.adapter.socket_mode.async_internals import ( + send_async_response, + run_async_bolt_app, +) +from slack_bolt.adapter.socket_mode.internals import run_bolt_app +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.response import BoltResponse + + +class SocketModeHandler(AsyncBaseSocketModeHandler): + app: App # type: ignore + app_token: str + client: SocketModeClient + + def __init__( # type: ignore + self, + app: App, # type: ignore + app_token: Optional[str] = None, + logger: Optional[Logger] = None, + web_client: Optional[AsyncWebClient] = None, + proxy: Optional[str] = None, + ping_interval: float = 10, + ): + self.app = app + self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] + self.client = SocketModeClient( + app_token=self.app_token, + logger=logger if logger is not None else app.logger, + web_client=web_client if web_client is not None else app.client, + proxy=proxy, + ping_interval=ping_interval, + ) + self.client.socket_mode_request_listeners.append(self.handle) + + async def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None: + start = time() + bolt_resp: BoltResponse = run_bolt_app(self.app, req) + await send_async_response(client, req, bolt_resp, start) + + +class AsyncSocketModeHandler(AsyncBaseSocketModeHandler): + app: AsyncApp # type: ignore + app_token: str + client: SocketModeClient + + def __init__( # type: ignore + self, + app: AsyncApp, # type: ignore + app_token: Optional[str] = None, + logger: Optional[Logger] = None, + web_client: Optional[AsyncWebClient] = None, + proxy: Optional[str] = None, + ping_interval: float = 10, + ): + self.app = app + self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] + self.client = SocketModeClient( + app_token=self.app_token, + logger=logger if logger is not None else app.logger, + web_client=web_client if web_client is not None else app.client, + proxy=proxy, + ping_interval=ping_interval, + ) + self.client.socket_mode_request_listeners.append(self.handle) + + async def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None: + start = time() + bolt_resp: BoltResponse = await run_async_bolt_app(self.app, req) + await send_async_response(client, req, bolt_resp, start) diff --git a/slack_bolt/adapter/socket_mode/async_base_handler.py b/slack_bolt/adapter/socket_mode/async_base_handler.py new file mode 100644 index 000000000..836074fad --- /dev/null +++ b/slack_bolt/adapter/socket_mode/async_base_handler.py @@ -0,0 +1,37 @@ +import asyncio +import logging +from typing import Union + +from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient +from slack_sdk.socket_mode.request import SocketModeRequest + +from slack_bolt import App +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.util.utils import get_boot_message + + +class AsyncBaseSocketModeHandler: + app: Union[App, AsyncApp] # type: ignore + client: AsyncBaseSocketModeClient + + async def handle( + self, client: AsyncBaseSocketModeClient, req: SocketModeRequest + ) -> None: + raise NotImplementedError() + + async def connect_async(self): + await self.client.connect() + + async def disconnect_async(self): + await self.client.disconnect() + + async def close_async(self): + await self.client.close() + + async def start_async(self): + await self.connect_async() + if self.app.logger.level > logging.INFO: + print(get_boot_message()) + else: + self.app.logger.info(get_boot_message()) + await asyncio.sleep(float("inf")) diff --git a/slack_bolt/adapter/socket_mode/async_handler.py b/slack_bolt/adapter/socket_mode/async_handler.py new file mode 100644 index 000000000..08edd8004 --- /dev/null +++ b/slack_bolt/adapter/socket_mode/async_handler.py @@ -0,0 +1 @@ +from .aiohttp import AsyncSocketModeHandler # noqa diff --git a/slack_bolt/adapter/socket_mode/async_internals.py b/slack_bolt/adapter/socket_mode/async_internals.py new file mode 100644 index 000000000..5b5750bd1 --- /dev/null +++ b/slack_bolt/adapter/socket_mode/async_internals.py @@ -0,0 +1,50 @@ +import json +import logging +from time import time + +from slack_sdk.socket_mode.async_client import AsyncBaseSocketModeClient +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.socket_mode.response import SocketModeResponse + +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +from slack_bolt.response import BoltResponse + + +async def run_async_bolt_app(app: AsyncApp, req: SocketModeRequest): # type: ignore + bolt_req: AsyncBoltRequest = AsyncBoltRequest(mode="socket_mode", body=req.payload) + bolt_resp: BoltResponse = await app.async_dispatch(bolt_req) + return bolt_resp + + +async def send_async_response( + client: AsyncBaseSocketModeClient, + req: SocketModeRequest, + bolt_resp: BoltResponse, + start_time: float, +): + if bolt_resp.status == 200: + content_type = bolt_resp.headers.get("content-type", [""])[0] + if bolt_resp.body is None or len(bolt_resp.body) == 0: + await client.send_socket_mode_response( + SocketModeResponse(envelope_id=req.envelope_id) + ) + elif content_type.startswith("application/json"): + dict_body = json.loads(bolt_resp.body) + await client.send_socket_mode_response( + SocketModeResponse(envelope_id=req.envelope_id, payload=dict_body) + ) + else: + await client.send_socket_mode_response( + SocketModeResponse( + envelope_id=req.envelope_id, + payload={"text": bolt_resp.body}, + ) + ) + if client.logger.level <= logging.DEBUG: + spent_time = int((time() - start_time) * 1000) + client.logger.debug(f"Response time: {spent_time} milliseconds") + else: + client.logger.info( + f"Unsuccessful Bolt execution result (status: {bolt_resp.status}, body: {bolt_resp.body})" + ) diff --git a/slack_bolt/adapter/socket_mode/base_handler.py b/slack_bolt/adapter/socket_mode/base_handler.py new file mode 100644 index 000000000..99568754e --- /dev/null +++ b/slack_bolt/adapter/socket_mode/base_handler.py @@ -0,0 +1,33 @@ +import logging +from threading import Event + +from slack_sdk.socket_mode.client import BaseSocketModeClient +from slack_sdk.socket_mode.request import SocketModeRequest + +from slack_bolt import App +from slack_bolt.util.utils import get_boot_message + + +class BaseSocketModeHandler: + app: App # type: ignore + client: BaseSocketModeClient + + def handle(self, client: BaseSocketModeClient, req: SocketModeRequest) -> None: + raise NotImplementedError() + + def connect(self): + self.client.connect() + + def disconnect(self): + self.client.disconnect() + + def close(self): + self.client.close() + + def start(self): + self.connect() + if self.app.logger.level > logging.INFO: + print(get_boot_message()) + else: + self.app.logger.info(get_boot_message()) + Event().wait() diff --git a/slack_bolt/adapter/socket_mode/builtin/__init__.py b/slack_bolt/adapter/socket_mode/builtin/__init__.py new file mode 100644 index 000000000..242a4d5fa --- /dev/null +++ b/slack_bolt/adapter/socket_mode/builtin/__init__.py @@ -0,0 +1,56 @@ +import os +from logging import Logger +from time import time +from typing import Optional + +from slack_sdk import WebClient +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.socket_mode.builtin import SocketModeClient + +from slack_bolt import App +from slack_bolt.adapter.socket_mode.base_handler import BaseSocketModeHandler +from slack_bolt.adapter.socket_mode.internals import run_bolt_app, send_response +from slack_bolt.response import BoltResponse + + +class SocketModeHandler(BaseSocketModeHandler): + app: App # type: ignore + app_token: str + client: SocketModeClient + + def __init__( # type: ignore + self, + app: App, # type: ignore + app_token: Optional[str] = None, + logger: Optional[Logger] = None, + web_client: Optional[WebClient] = None, + proxy: Optional[str] = None, + auto_reconnect_enabled: bool = True, + trace_enabled: bool = False, + all_message_trace_enabled: bool = False, + ping_pong_trace_enabled: bool = False, + ping_interval: float = 10, + receive_buffer_size: int = 1024, + concurrency: int = 10, + ): + self.app = app + self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] + self.client = SocketModeClient( + app_token=self.app_token, + logger=logger if logger is not None else app.logger, + web_client=web_client if web_client is not None else app.client, + proxy=proxy if proxy is not None else app.client.proxy, + auto_reconnect_enabled=auto_reconnect_enabled, + trace_enabled=trace_enabled, + all_message_trace_enabled=all_message_trace_enabled, + ping_pong_trace_enabled=ping_pong_trace_enabled, + ping_interval=ping_interval, + receive_buffer_size=receive_buffer_size, + concurrency=concurrency, + ) + self.client.socket_mode_request_listeners.append(self.handle) + + def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None: + start = time() + bolt_resp: BoltResponse = run_bolt_app(self.app, req) + send_response(client, req, bolt_resp, start) diff --git a/slack_bolt/adapter/socket_mode/internals.py b/slack_bolt/adapter/socket_mode/internals.py new file mode 100644 index 000000000..4f7f4d68c --- /dev/null +++ b/slack_bolt/adapter/socket_mode/internals.py @@ -0,0 +1,50 @@ +import json +import logging +from time import time + +from slack_sdk.socket_mode.client import BaseSocketModeClient +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.socket_mode.response import SocketModeResponse + +from slack_bolt.app import App +from slack_bolt.request import BoltRequest +from slack_bolt.response import BoltResponse + + +def run_bolt_app(app: App, req: SocketModeRequest): # type: ignore + bolt_req: BoltRequest = BoltRequest(mode="socket_mode", body=req.payload) + bolt_resp: BoltResponse = app.dispatch(bolt_req) + return bolt_resp + + +def send_response( + client: BaseSocketModeClient, + req: SocketModeRequest, + bolt_resp: BoltResponse, + start_time: float, +): + if bolt_resp.status == 200: + content_type = bolt_resp.headers.get("content-type", [""])[0] + if bolt_resp.body is None or len(bolt_resp.body) == 0: + client.send_socket_mode_response( + SocketModeResponse(envelope_id=req.envelope_id) + ) + elif content_type.startswith("application/json"): + dict_body = json.loads(bolt_resp.body) + client.send_socket_mode_response( + SocketModeResponse(envelope_id=req.envelope_id, payload=dict_body) + ) + else: + client.send_socket_mode_response( + SocketModeResponse( + envelope_id=req.envelope_id, payload={"text": bolt_resp.body} + ) + ) + + if client.logger.level <= logging.DEBUG: + spent_time = int((time() - start_time) * 1000) + client.logger.debug(f"Response time: {spent_time} milliseconds") + else: + client.logger.info( + f"Unsuccessful Bolt execution result (status: {bolt_resp.status}, body: {bolt_resp.body})" + ) diff --git a/slack_bolt/adapter/socket_mode/websocket_client/__init__.py b/slack_bolt/adapter/socket_mode/websocket_client/__init__.py new file mode 100644 index 000000000..589edb80d --- /dev/null +++ b/slack_bolt/adapter/socket_mode/websocket_client/__init__.py @@ -0,0 +1,54 @@ +import os +from logging import Logger +from time import time +from typing import Optional, Tuple + +from slack_sdk import WebClient +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.socket_mode.websocket_client import SocketModeClient + +from slack_bolt import App +from slack_bolt.adapter.socket_mode.base_handler import BaseSocketModeHandler +from slack_bolt.adapter.socket_mode.internals import run_bolt_app, send_response +from slack_bolt.response import BoltResponse + + +class SocketModeHandler(BaseSocketModeHandler): + app: App # type: ignore + app_token: str + client: SocketModeClient + + def __init__( # type: ignore + self, + app: App, # type: ignore + app_token: Optional[str] = None, + logger: Optional[Logger] = None, + web_client: Optional[WebClient] = None, + ping_interval: float = 10, + concurrency: int = 10, + http_proxy_host: Optional[str] = None, + http_proxy_port: Optional[int] = None, + http_proxy_auth: Optional[Tuple[str, str]] = None, + proxy_type: Optional[str] = None, + trace_enabled: bool = False, + ): + self.app = app + self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] + self.client = SocketModeClient( + app_token=self.app_token, + logger=logger if logger is not None else app.logger, + web_client=web_client if web_client is not None else app.client, + ping_interval=ping_interval, + concurrency=concurrency, + http_proxy_host=http_proxy_host, + http_proxy_port=http_proxy_port, + http_proxy_auth=http_proxy_auth, + proxy_type=proxy_type, + trace_enabled=trace_enabled, + ) + self.client.socket_mode_request_listeners.append(self.handle) + + def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None: + start = time() + bolt_resp: BoltResponse = run_bolt_app(self.app, req) + send_response(client, req, bolt_resp, start) diff --git a/slack_bolt/adapter/socket_mode/websockets/__init__.py b/slack_bolt/adapter/socket_mode/websockets/__init__.py new file mode 100644 index 000000000..878eaf5e6 --- /dev/null +++ b/slack_bolt/adapter/socket_mode/websockets/__init__.py @@ -0,0 +1,76 @@ +import os +from logging import Logger +from time import time +from typing import Optional + +from slack_sdk.socket_mode.websockets import SocketModeClient +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt import App +from slack_bolt.adapter.socket_mode.async_base_handler import AsyncBaseSocketModeHandler +from slack_bolt.adapter.socket_mode.async_internals import ( + send_async_response, + run_async_bolt_app, +) +from slack_bolt.adapter.socket_mode.internals import run_bolt_app +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.response import BoltResponse + + +class SocketModeHandler(AsyncBaseSocketModeHandler): + app: App # type: ignore + app_token: str + client: SocketModeClient + + def __init__( # type: ignore + self, + app: App, # type: ignore + app_token: Optional[str] = None, + logger: Optional[Logger] = None, + web_client: Optional[AsyncWebClient] = None, + ping_interval: float = 10, + ): + self.app = app + self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] + self.client = SocketModeClient( + app_token=self.app_token, + logger=logger if logger is not None else app.logger, + web_client=web_client if web_client is not None else app.client, + ping_interval=ping_interval, + ) + self.client.socket_mode_request_listeners.append(self.handle) + + async def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None: + start = time() + bolt_resp: BoltResponse = run_bolt_app(self.app, req) + await send_async_response(client, req, bolt_resp, start) + + +class AsyncSocketModeHandler(AsyncBaseSocketModeHandler): + app: AsyncApp # type: ignore + app_token: str + client: SocketModeClient + + def __init__( # type: ignore + self, + app: AsyncApp, # type: ignore + app_token: Optional[str] = None, + logger: Optional[Logger] = None, + web_client: Optional[AsyncWebClient] = None, + ping_interval: float = 10, + ): + self.app = app + self.app_token = app_token or os.environ["SLACK_APP_TOKEN"] + self.client = SocketModeClient( + app_token=self.app_token, + logger=logger if logger is not None else app.logger, + web_client=web_client if web_client is not None else app.client, + ping_interval=ping_interval, + ) + self.client.socket_mode_request_listeners.append(self.handle) + + async def handle(self, client: SocketModeClient, req: SocketModeRequest) -> None: + start = time() + bolt_resp: BoltResponse = await run_async_bolt_app(self.app, req) + await send_async_response(client, req, bolt_resp, start) diff --git a/slack_bolt/kwargs_injection/async_utils.py b/slack_bolt/kwargs_injection/async_utils.py index a4b069d72..dae473eea 100644 --- a/slack_bolt/kwargs_injection/async_utils.py +++ b/slack_bolt/kwargs_injection/async_utils.py @@ -1,4 +1,5 @@ # pytype: skip-file +import inspect import logging from typing import Callable, Dict, Optional, Any, Sequence @@ -25,6 +26,7 @@ def build_async_required_kwargs( request: AsyncBoltRequest, response: Optional[BoltResponse], next_func: Callable[[], None] = None, + this_func: Optional[Callable] = None, ) -> Dict[str, Any]: all_available_args = { "logger": logger, @@ -73,8 +75,12 @@ def build_async_required_kwargs( if first_arg_name in {"self", "cls"}: required_arg_names.pop(0) elif first_arg_name not in all_available_args.keys(): - logger.warning(warning_skip_uncommon_arg_name(first_arg_name)) - required_arg_names.pop(0) + if this_func is None: + logger.warning(warning_skip_uncommon_arg_name(first_arg_name)) + required_arg_names.pop(0) + elif inspect.ismethod(this_func): + # We are sure that we should skip manipulating this arg + required_arg_names.pop(0) kwargs: Dict[str, Any] = { k: v for k, v in all_available_args.items() if k in required_arg_names diff --git a/slack_bolt/kwargs_injection/utils.py b/slack_bolt/kwargs_injection/utils.py index e10febb59..2685ba342 100644 --- a/slack_bolt/kwargs_injection/utils.py +++ b/slack_bolt/kwargs_injection/utils.py @@ -1,4 +1,5 @@ # pytype: skip-file +import inspect import logging from typing import Callable, Dict, Optional, Any, Sequence @@ -25,6 +26,7 @@ def build_required_kwargs( request: BoltRequest, response: Optional[BoltResponse], next_func: Callable[[], None] = None, + this_func: Optional[Callable] = None, ) -> Dict[str, Any]: all_available_args = { "logger": logger, @@ -73,8 +75,12 @@ def build_required_kwargs( if first_arg_name in {"self", "cls"}: required_arg_names.pop(0) elif first_arg_name not in all_available_args.keys(): - logger.warning(warning_skip_uncommon_arg_name(first_arg_name)) - required_arg_names.pop(0) + if this_func is None: + logger.warning(warning_skip_uncommon_arg_name(first_arg_name)) + required_arg_names.pop(0) + elif inspect.ismethod(this_func): + # We are sure that we should skip manipulating this arg + required_arg_names.pop(0) kwargs: Dict[str, Any] = { k: v for k, v in all_available_args.items() if k in required_arg_names diff --git a/slack_bolt/lazy_listener/async_internals.py b/slack_bolt/lazy_listener/async_internals.py index a3e3bfe79..db0d21eb9 100644 --- a/slack_bolt/lazy_listener/async_internals.py +++ b/slack_bolt/lazy_listener/async_internals.py @@ -23,6 +23,7 @@ async def request_wired_wrapper() -> None: required_arg_names=arg_names, request=request, response=None, + this_func=internal_func, ) ) except Exception as e: diff --git a/slack_bolt/lazy_listener/internals.py b/slack_bolt/lazy_listener/internals.py index f2934ef69..95be376ae 100644 --- a/slack_bolt/lazy_listener/internals.py +++ b/slack_bolt/lazy_listener/internals.py @@ -23,6 +23,7 @@ def request_wired_func_wrapper() -> None: required_arg_names=arg_names, request=request, response=None, + this_func=func, ) ) except Exception as e: diff --git a/slack_bolt/listener/async_listener.py b/slack_bolt/listener/async_listener.py index 567a51e17..19326fe91 100644 --- a/slack_bolt/listener/async_listener.py +++ b/slack_bolt/listener/async_listener.py @@ -117,6 +117,7 @@ async def run_ack_function( required_arg_names=self.arg_names, request=request, response=response, + this_func=self.ack_function, ) ) diff --git a/slack_bolt/listener/custom_listener.py b/slack_bolt/listener/custom_listener.py index 99f8c353f..b38e80324 100644 --- a/slack_bolt/listener/custom_listener.py +++ b/slack_bolt/listener/custom_listener.py @@ -52,5 +52,6 @@ def run_ack_function( required_arg_names=self.arg_names, request=request, response=response, + this_func=self.ack_function, ) ) diff --git a/slack_bolt/listener_matcher/async_builtins.py b/slack_bolt/listener_matcher/async_builtins.py index 95a7b32e8..f8d05ce52 100644 --- a/slack_bolt/listener_matcher/async_builtins.py +++ b/slack_bolt/listener_matcher/async_builtins.py @@ -14,5 +14,6 @@ async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool required_arg_names=self.arg_names, request=req, response=resp, + this_func=self.func, ) ) diff --git a/slack_bolt/listener_matcher/async_listener_matcher.py b/slack_bolt/listener_matcher/async_listener_matcher.py index 29a107e3e..b21872ed9 100644 --- a/slack_bolt/listener_matcher/async_listener_matcher.py +++ b/slack_bolt/listener_matcher/async_listener_matcher.py @@ -45,6 +45,7 @@ async def async_matches(self, req: AsyncBoltRequest, resp: BoltResponse) -> bool required_arg_names=self.arg_names, request=req, response=resp, + this_func=self.func, ) ) diff --git a/slack_bolt/listener_matcher/builtins.py b/slack_bolt/listener_matcher/builtins.py index 27efa2c24..50b64fea6 100644 --- a/slack_bolt/listener_matcher/builtins.py +++ b/slack_bolt/listener_matcher/builtins.py @@ -50,6 +50,7 @@ def matches(self, req: BoltRequest, resp: BoltResponse) -> bool: required_arg_names=self.arg_names, request=req, response=resp, + this_func=self.func, ) ) diff --git a/slack_bolt/listener_matcher/custom_listener_matcher.py b/slack_bolt/listener_matcher/custom_listener_matcher.py index f96c4da9e..4e07006d4 100644 --- a/slack_bolt/listener_matcher/custom_listener_matcher.py +++ b/slack_bolt/listener_matcher/custom_listener_matcher.py @@ -28,5 +28,6 @@ def matches(self, req: BoltRequest, resp: BoltResponse) -> bool: required_arg_names=self.arg_names, request=req, response=resp, + this_func=self.func, ) ) diff --git a/slack_bolt/middleware/async_custom_middleware.py b/slack_bolt/middleware/async_custom_middleware.py index 220a0723a..be44cf2db 100644 --- a/slack_bolt/middleware/async_custom_middleware.py +++ b/slack_bolt/middleware/async_custom_middleware.py @@ -39,6 +39,7 @@ async def async_process( request=req, response=resp, next_func=next, + this_func=self.func, ) ) diff --git a/slack_bolt/middleware/custom_middleware.py b/slack_bolt/middleware/custom_middleware.py index 016b42ee9..f3e4afff8 100644 --- a/slack_bolt/middleware/custom_middleware.py +++ b/slack_bolt/middleware/custom_middleware.py @@ -35,6 +35,7 @@ def process( request=req, response=resp, next_func=next, + this_func=self.func, ) ) diff --git a/slack_bolt/version.py b/slack_bolt/version.py index 9b102be76..4ab11ba1c 100644 --- a/slack_bolt/version.py +++ b/slack_bolt/version.py @@ -1 +1 @@ -__version__ = "1.1.5" +__version__ = "1.2.0rc2" diff --git a/tests/adapter_tests/socket_mode/__init__.py b/tests/adapter_tests/socket_mode/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests/socket_mode/mock_socket_mode_server.py b/tests/adapter_tests/socket_mode/mock_socket_mode_server.py new file mode 100644 index 000000000..2b778910c --- /dev/null +++ b/tests/adapter_tests/socket_mode/mock_socket_mode_server.py @@ -0,0 +1,42 @@ +import logging +from typing import List + +socket_mode_envelopes = [ + """{"envelope_id":"57d6a792-4d35-4d0b-b6aa-3361493e1caf","payload":{"type":"shortcut","token":"xxx","action_ts":"1610198080.300836","team":{"id":"T111","domain":"seratch"},"user":{"id":"U111","username":"seratch","team_id":"T111"},"is_enterprise_install":false,"enterprise":null,"callback_id":"do-something","trigger_id":"111.222.xxx"},"type":"interactive","accepts_response_payload":false}""", + """{"envelope_id":"1d3c79ab-0ffb-41f3-a080-d19e85f53649","payload":{"token":"xxx","team_id":"T111","team_domain":"xxx","channel_id":"C111","channel_name":"random","user_id":"U111","user_name":"seratch","command":"/hello-socket-mode","text":"","api_app_id":"A111","response_url":"https://hooks.slack.com/commands/T111/111/xxx","trigger_id":"111.222.xxx"},"type":"slash_commands","accepts_response_payload":true}""", + """{"envelope_id":"08cfc559-d933-402e-a5c1-79e135afaae4","payload":{"token":"xxx","team_id":"T111","api_app_id":"A111","event":{"client_msg_id":"c9b466b5-845c-49c6-a371-57ae44359bf1","type":"message","text":"<@W111>","user":"U111","ts":"1610197986.000300","team":"T111","blocks":[{"type":"rich_text","block_id":"1HBPc","elements":[{"type":"rich_text_section","elements":[{"type":"user","user_id":"U111"}]}]}],"channel":"C111","event_ts":"1610197986.000300","channel_type":"channel"},"type":"event_callback","event_id":"Ev111","event_time":1610197986,"authorizations":[{"enterprise_id":null,"team_id":"T111","user_id":"U111","is_bot":true,"is_enterprise_install":false}],"is_ext_shared_channel":false,"event_context":"1-message-T111-C111"},"type":"events_api","accepts_response_payload":false,"retry_attempt":1,"retry_reason":"timeout"}""", +] + +from flask import Flask +from flask_sockets import Sockets + + +def start_socket_mode_server(self, port: int): + def _start_socket_mode_server(): + logger = logging.getLogger(__name__) + app: Flask = Flask(__name__) + sockets: Sockets = Sockets(app) + + envelopes_to_consume: List[str] = list(socket_mode_envelopes) + + @sockets.route("/link") + def link(ws): + while not ws.closed: + message = ws.read_message() + if message is not None: + if len(envelopes_to_consume) > 0: + e = envelopes_to_consume.pop(0) + logger.debug(f"Send an envelope: {e}") + ws.send(e) + + logger.debug(f"Server received a message: {message}") + ws.send(message) + + from gevent import pywsgi + from geventwebsocket.handler import WebSocketHandler + + server = pywsgi.WSGIServer(("", port), app, handler_class=WebSocketHandler) + self.server = server + server.serve_forever(stop_timeout=1) + + return _start_socket_mode_server diff --git a/tests/adapter_tests/socket_mode/mock_web_api_server.py b/tests/adapter_tests/socket_mode/mock_web_api_server.py new file mode 100644 index 000000000..ac30148d7 --- /dev/null +++ b/tests/adapter_tests/socket_mode/mock_web_api_server.py @@ -0,0 +1,153 @@ +import json +import logging +import re +import threading +from http import HTTPStatus +from http.server import HTTPServer, SimpleHTTPRequestHandler +from typing import Type +from unittest import TestCase +from urllib.parse import urlparse, parse_qs + + +class MockHandler(SimpleHTTPRequestHandler): + protocol_version = "HTTP/1.1" + default_request_version = "HTTP/1.1" + logger = logging.getLogger(__name__) + + pattern_for_language = re.compile("python/(\\S+)", re.IGNORECASE) + pattern_for_package_identifier = re.compile("slackclient/(\\S+)") + + def is_valid_user_agent(self): + user_agent = self.headers["User-Agent"] + return self.pattern_for_language.search( + user_agent + ) and self.pattern_for_package_identifier.search(user_agent) + + def is_valid_token(self): + if self.path.startswith("oauth"): + return True + return "Authorization" in self.headers and ( + str(self.headers["Authorization"]).startswith("Bearer xoxb-") + or str(self.headers["Authorization"]).startswith("Bearer xapp-") + ) + + def set_common_headers(self): + self.send_header("content-type", "application/json;charset=utf-8") + self.send_header("connection", "close") + self.end_headers() + + invalid_auth = { + "ok": False, + "error": "invalid_auth", + } + + not_found = { + "ok": False, + "error": "test_data_not_found", + } + + def _handle(self): + try: + if self.is_valid_token() and self.is_valid_user_agent(): + parsed_path = urlparse(self.path) + + len_header = self.headers.get("Content-Length") or 0 + content_len = int(len_header) + post_body = self.rfile.read(content_len) + request_body = None + if post_body: + try: + post_body = post_body.decode("utf-8") + if post_body.startswith("{"): + request_body = json.loads(post_body) + else: + request_body = { + k: v[0] for k, v in parse_qs(post_body).items() + } + except UnicodeDecodeError: + pass + else: + if parsed_path and parsed_path.query: + request_body = { + k: v[0] for k, v in parse_qs(parsed_path.query).items() + } + + body = {"ok": False, "error": "internal_error"} + if self.path == "/auth.test": + body = { + "ok": True, + "url": "https://xyz.slack.com/", + "team": "Testing Workspace", + "user": "bot-user", + "team_id": "T111", + "user_id": "W11", + "bot_id": "B111", + "enterprise_id": "E111", + "is_enterprise_install": False, + } + if self.path == "/apps.connections.open": + body = { + "ok": True, + "url": "ws://localhost:3011/link/?ticket=xxx&app_id=yyy", + } + if self.path == "/api.test" and request_body: + body = {"ok": True, "args": request_body} + else: + body = self.invalid_auth + + if not body: + body = self.not_found + + self.send_response(HTTPStatus.OK) + self.set_common_headers() + self.wfile.write(json.dumps(body).encode("utf-8")) + self.wfile.close() + + except Exception as e: + self.logger.error(str(e), exc_info=True) + raise + + def do_GET(self): + self._handle() + + def do_POST(self): + self._handle() + + +class MockServerThread(threading.Thread): + def __init__( + self, test: TestCase, handler: Type[SimpleHTTPRequestHandler] = MockHandler + ): + threading.Thread.__init__(self) + self.handler = handler + self.test = test + + def run(self): + self.server = HTTPServer(("localhost", 8888), self.handler) + self.test.server_url = "http://localhost:8888" + self.test.host, self.test.port = self.server.socket.getsockname() + self.test.server_started.set() # threading.Event() + + self.test = None + try: + self.server.serve_forever() + finally: + self.server.server_close() + + def stop(self): + self.server.shutdown() + self.join() + + +def setup_mock_web_api_server(test: TestCase): + test.server_started = threading.Event() + test.thread = MockServerThread(test) + test.thread.start() + + test.server_started.wait() + + +def cleanup_mock_web_api_server(test: TestCase): + test.thread.stop() + + test.thread = None diff --git a/tests/adapter_tests/socket_mode/test_interactions_builtin.py b/tests/adapter_tests/socket_mode/test_interactions_builtin.py new file mode 100644 index 000000000..cee5e3248 --- /dev/null +++ b/tests/adapter_tests/socket_mode/test_interactions_builtin.py @@ -0,0 +1,75 @@ +import logging +import time +from threading import Thread + +from slack_sdk import WebClient + +from slack_bolt import App +from slack_bolt.adapter.socket_mode import SocketModeHandler +from .mock_socket_mode_server import ( + start_socket_mode_server, +) +from .mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from ...utils import remove_os_env_temporarily, restore_os_env + + +class TestSocketModeBuiltin: + logger = logging.getLogger(__name__) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + self.web_client = WebClient( + token="xoxb-api_test", + base_url="http://localhost:8888", + ) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def test_interactions(self): + t = Thread(target=start_socket_mode_server(self, 3011)) + t.daemon = True + t.start() + time.sleep(2) # wait for the server + + app = App(client=self.web_client) + + result = {"shortcut": False, "command": False} + + @app.shortcut("do-something") + def shortcut_handler(ack): + result["shortcut"] = True + ack() + + @app.command("/hello-socket-mode") + def command_handler(ack): + result["command"] = True + ack() + + handler = SocketModeHandler( + app_token="xapp-A111-222-xyz", + app=app, + trace_enabled=True, + ) + try: + handler.client.ping_pong_trace_enabled = True + handler.client.wss_uri = "ws://127.0.0.1:3011/link" + + handler.connect() + assert handler.client.is_connected() is True + time.sleep(2) # wait for the message receiver + + handler.client.send_message("foo") + + time.sleep(2) + assert result["shortcut"] is True + assert result["command"] is True + finally: + handler.client.close() + self.server.stop() + self.server.close() diff --git a/tests/adapter_tests/socket_mode/test_interactions_web_client.py b/tests/adapter_tests/socket_mode/test_interactions_web_client.py new file mode 100644 index 000000000..7fd3ca0fc --- /dev/null +++ b/tests/adapter_tests/socket_mode/test_interactions_web_client.py @@ -0,0 +1,74 @@ +import logging +import time +from threading import Thread + +from slack_sdk import WebClient + +from slack_bolt import App +from slack_bolt.adapter.socket_mode.websocket_client import SocketModeHandler +from .mock_socket_mode_server import ( + start_socket_mode_server, +) +from .mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from ...utils import remove_os_env_temporarily, restore_os_env + + +class TestSocketModeWebsocketClient: + logger = logging.getLogger(__name__) + + def setup_method(self): + self.old_os_env = remove_os_env_temporarily() + setup_mock_web_api_server(self) + self.web_client = WebClient( + token="xoxb-api_test", + base_url="http://localhost:8888", + ) + + def teardown_method(self): + cleanup_mock_web_api_server(self) + restore_os_env(self.old_os_env) + + def test_interactions(self): + t = Thread(target=start_socket_mode_server(self, 3012)) + t.daemon = True + t.start() + time.sleep(1) # wait for the server + + app = App(client=self.web_client) + + result = {"shortcut": False, "command": False} + + @app.shortcut("do-something") + def shortcut_handler(ack): + result["shortcut"] = True + ack() + + @app.command("/hello-socket-mode") + def command_handler(ack): + result["command"] = True + ack() + + handler = SocketModeHandler( + app_token="xapp-A111-222-xyz", + app=app, + trace_enabled=True, + ) + try: + handler.client.wss_uri = "ws://localhost:3012/link" + + handler.connect() + assert handler.client.is_connected() is True + time.sleep(2) # wait for the message receiver + + handler.client.send_message("foo") + + time.sleep(2) + assert result["shortcut"] is True + assert result["command"] is True + finally: + handler.client.close() + self.server.stop() + self.server.close() diff --git a/tests/adapter_tests_async/socket_mode/__init__.py b/tests/adapter_tests_async/socket_mode/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py b/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py new file mode 100644 index 000000000..c8eb5b5ab --- /dev/null +++ b/tests/adapter_tests_async/socket_mode/test_async_aiohttp.py @@ -0,0 +1,78 @@ +import asyncio +from threading import Thread + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.adapter.socket_mode.aiohttp import AsyncSocketModeHandler +from slack_bolt.app.async_app import AsyncApp +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env +from ...adapter_tests.socket_mode.mock_socket_mode_server import ( + start_socket_mode_server, +) + + +class TestSocketModeAiohttp: + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture + def event_loop(self): + old_os_env = remove_os_env_temporarily() + try: + setup_mock_web_api_server(self) + loop = asyncio.get_event_loop() + yield loop + loop.close() + cleanup_mock_web_api_server(self) + finally: + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_events(self): + t = Thread(target=start_socket_mode_server(self, 3021)) + t.daemon = True + t.start() + await asyncio.sleep(1) # wait for the server + + app = AsyncApp(client=self.web_client) + + result = {"shortcut": False, "command": False} + + @app.shortcut("do-something") + async def shortcut_handler(ack): + result["shortcut"] = True + await ack() + + @app.command("/hello-socket-mode") + async def command_handler(ack): + result["command"] = True + await ack() + + handler = AsyncSocketModeHandler( + app_token="xapp-A111-222-xyz", + app=app, + ) + try: + handler.client.wss_uri = "ws://localhost:3021/link" + + await handler.connect_async() + await asyncio.sleep(2) # wait for the message receiver + + await handler.client.send_message("foo") + + await asyncio.sleep(2) + assert result["shortcut"] is True + assert result["command"] is True + finally: + await handler.client.close() + self.server.stop() + self.server.close() diff --git a/tests/adapter_tests_async/socket_mode/test_async_websockets.py b/tests/adapter_tests_async/socket_mode/test_async_websockets.py new file mode 100644 index 000000000..c47d2b7fe --- /dev/null +++ b/tests/adapter_tests_async/socket_mode/test_async_websockets.py @@ -0,0 +1,78 @@ +import asyncio +from threading import Thread + +import pytest +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.adapter.socket_mode.websockets import AsyncSocketModeHandler +from slack_bolt.app.async_app import AsyncApp +from tests.mock_web_api_server import ( + setup_mock_web_api_server, + cleanup_mock_web_api_server, +) +from tests.utils import remove_os_env_temporarily, restore_os_env +from ...adapter_tests.socket_mode.mock_socket_mode_server import ( + start_socket_mode_server, +) + + +class TestSocketModeWebsockets: + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + web_client = AsyncWebClient( + token=valid_token, + base_url=mock_api_server_base_url, + ) + + @pytest.fixture + def event_loop(self): + old_os_env = remove_os_env_temporarily() + try: + setup_mock_web_api_server(self) + loop = asyncio.get_event_loop() + yield loop + loop.close() + cleanup_mock_web_api_server(self) + finally: + restore_os_env(old_os_env) + + @pytest.mark.asyncio + async def test_events(self): + t = Thread(target=start_socket_mode_server(self, 3022)) + t.daemon = True + t.start() + await asyncio.sleep(1) # wait for the server + + app = AsyncApp(client=self.web_client) + + result = {"shortcut": False, "command": False} + + @app.shortcut("do-something") + async def shortcut_handler(ack): + result["shortcut"] = True + await ack() + + @app.command("/hello-socket-mode") + async def command_handler(ack): + result["command"] = True + await ack() + + handler = AsyncSocketModeHandler( + app_token="xapp-A111-222-xyz", + app=app, + ) + try: + handler.client.wss_uri = "ws://localhost:3022/link" + + await handler.connect_async() + await asyncio.sleep(2) # wait for the message receiver + + await handler.client.send_message("foo") + + await asyncio.sleep(2) + assert result["shortcut"] is True + assert result["command"] is True + finally: + await handler.client.close() + self.server.stop() + self.server.close() diff --git a/tests/scenario_tests/test_app_using_methods_in_class.py b/tests/scenario_tests/test_app_using_methods_in_class.py index 4102c819c..642d0641d 100644 --- a/tests/scenario_tests/test_app_using_methods_in_class.py +++ b/tests/scenario_tests/test_app_using_methods_in_class.py @@ -1,3 +1,4 @@ +import inspect import json from time import time, sleep from typing import Callable @@ -31,6 +32,29 @@ def teardown_method(self): cleanup_mock_web_api_server(self) restore_os_env(self.old_os_env) + def test_inspect_behaviors(self): + def f(): + pass + + assert inspect.ismethod(f) is False + + class A: + def b(self): + pass + + @classmethod + def c(cls): + pass + + @staticmethod + def d(): + pass + + a = A() + assert inspect.ismethod(a.b) is True + assert inspect.ismethod(A.c) is True + assert inspect.ismethod(A.d) is False + def run_app_and_verify(self, app: App): payload = { "type": "message_action", @@ -119,12 +143,24 @@ def test_instance_methods_uncommon_name(self): app.shortcut("test-shortcut")(awesome.instance_method2) self.run_app_and_verify(app) + def test_instance_methods_uncommon_name_3(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + awesome = AwesomeClass("Slackbot") + app.use(awesome.instance_middleware) + app.shortcut("test-shortcut")(awesome.instance_method3) + self.run_app_and_verify(app) + def test_static_methods(self): app = App(client=self.web_client, signing_secret=self.signing_secret) app.use(AwesomeClass.static_middleware) app.shortcut("test-shortcut")(AwesomeClass.static_method) self.run_app_and_verify(app) + def test_invalid_arg_in_func(self): + app = App(client=self.web_client, signing_secret=self.signing_secret) + app.shortcut("test-shortcut")(top_level_function) + self.run_app_and_verify(app) + class AwesomeClass: def __init__(self, name: str): @@ -159,7 +195,20 @@ def instance_method2(whatever, context: BoltContext, say: Say, ack: Ack): ack() say(f"Hello <@{context.user_id}>! My name is {whatever.name}") + text = "hello world" + + def instance_method3(this, ack, logger, say): + ack() + logger.debug(this.text) + say(f"Hi there!") + @staticmethod def static_method(context: BoltContext, say: Say, ack: Ack): ack() say(f"Hello <@{context.user_id}>!") + + +def top_level_function(invalid_arg, ack, say): + assert invalid_arg is None + ack() + say("Hi") diff --git a/tests/scenario_tests_async/test_app_using_methods_in_class.py b/tests/scenario_tests_async/test_app_using_methods_in_class.py index a1131fd35..f58be3af5 100644 --- a/tests/scenario_tests_async/test_app_using_methods_in_class.py +++ b/tests/scenario_tests_async/test_app_using_methods_in_class.py @@ -1,4 +1,5 @@ import asyncio +import inspect import json from time import time from typing import Callable @@ -41,6 +42,29 @@ def event_loop(self): finally: restore_os_env(old_os_env) + def test_inspect_behaviors(self): + async def f(): + pass + + assert inspect.ismethod(f) is False + + class A: + async def b(self): + pass + + @classmethod + async def c(cls): + pass + + @staticmethod + async def d(): + pass + + a = A() + assert inspect.ismethod(a.b) is True + assert inspect.ismethod(A.c) is True + assert inspect.ismethod(A.d) is False + async def run_app_and_verify(self, app: AsyncApp): payload = { "type": "message_action", @@ -126,13 +150,21 @@ async def test_instance_methods(self): await self.run_app_and_verify(app) @pytest.mark.asyncio - async def test_instance_methods_uncommon_name(self): + async def test_instance_methods_uncommon_name_1(self): app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) awesome = AwesomeClass("Slackbot") app.use(awesome.instance_middleware) app.shortcut("test-shortcut")(awesome.instance_method2) await self.run_app_and_verify(app) + @pytest.mark.asyncio + async def test_instance_methods_uncommon_name_2(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + awesome = AwesomeClass("Slackbot") + app.use(awesome.instance_middleware) + app.shortcut("test-shortcut")(awesome.instance_method3) + await self.run_app_and_verify(app) + @pytest.mark.asyncio async def test_static_methods(self): app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) @@ -140,6 +172,12 @@ async def test_static_methods(self): app.shortcut("test-shortcut")(AwesomeClass.static_method) await self.run_app_and_verify(app) + @pytest.mark.asyncio + async def test_invalid_arg_in_func(self): + app = AsyncApp(client=self.web_client, signing_secret=self.signing_secret) + app.shortcut("test-shortcut")(top_level_function) + await self.run_app_and_verify(app) + class AwesomeClass: def __init__(self, name: str): @@ -182,7 +220,20 @@ async def instance_method2( await ack() await say(f"Hello <@{context.user_id}>! My name is {whatever.name}") + text = "hello world" + + async def instance_method3(this, ack, logger, say): + await ack() + logger.debug(this.text) + await say(f"Hi there!") + @staticmethod async def static_method(context: AsyncBoltContext, say: AsyncSay, ack: AsyncAck): await ack() await say(f"Hello <@{context.user_id}>!") + + +async def top_level_function(invalid_arg, ack, say): + assert invalid_arg is None + await ack() + await say("Hi")