diff --git a/slack_bolt/context/respond/async_respond.py b/slack_bolt/context/respond/async_respond.py index a589ef792..151525ee3 100644 --- a/slack_bolt/context/respond/async_respond.py +++ b/slack_bolt/context/respond/async_respond.py @@ -34,6 +34,7 @@ async def __call__( delete_original: Optional[bool] = None, unfurl_links: Optional[bool] = None, unfurl_media: Optional[bool] = None, + thread_ts: Optional[str] = None, ) -> WebhookResponse: if self.response_url is not None: client = AsyncWebhookClient( @@ -52,6 +53,7 @@ async def __call__( delete_original=delete_original, unfurl_links=unfurl_links, unfurl_media=unfurl_media, + thread_ts=thread_ts, ) return await client.send_dict(message) elif isinstance(text_or_whole_response, dict): diff --git a/slack_bolt/context/respond/internals.py b/slack_bolt/context/respond/internals.py index 21acfaf43..f32976924 100644 --- a/slack_bolt/context/respond/internals.py +++ b/slack_bolt/context/respond/internals.py @@ -15,6 +15,7 @@ def _build_message( delete_original: Optional[bool] = None, unfurl_links: Optional[bool] = None, unfurl_media: Optional[bool] = None, + thread_ts: Optional[str] = None, ) -> Dict[str, Any]: message = {"text": text} if blocks is not None and len(blocks) > 0: @@ -31,4 +32,6 @@ def _build_message( message["unfurl_links"] = unfurl_links if unfurl_media is not None: message["unfurl_media"] = unfurl_media + if thread_ts is not None: + message["thread_ts"] = thread_ts return message diff --git a/slack_bolt/context/respond/respond.py b/slack_bolt/context/respond/respond.py index 5cf33e498..59ef11870 100644 --- a/slack_bolt/context/respond/respond.py +++ b/slack_bolt/context/respond/respond.py @@ -34,6 +34,7 @@ def __call__( delete_original: Optional[bool] = None, unfurl_links: Optional[bool] = None, unfurl_media: Optional[bool] = None, + thread_ts: Optional[str] = None, ) -> WebhookResponse: if self.response_url is not None: client = WebhookClient( @@ -53,6 +54,7 @@ def __call__( delete_original=delete_original, unfurl_links=unfurl_links, unfurl_media=unfurl_media, + thread_ts=thread_ts, ) return client.send_dict(message) elif isinstance(text_or_whole_response, dict): diff --git a/tests/scenario_tests/test_block_actions_respond.py b/tests/scenario_tests/test_block_actions_respond.py new file mode 100644 index 000000000..2a77261f9 --- /dev/null +++ b/tests/scenario_tests/test_block_actions_respond.py @@ -0,0 +1,174 @@ +from slack_sdk import WebClient + +from slack_bolt import BoltRequest, App, Say, Respond, Ack +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 + + +class TestBlockActionsRespond: + signing_secret = "secret" + valid_token = "xoxb-valid" + mock_api_server_base_url = "http://localhost:8888" + 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 test_mock_server_is_running(self): + resp = self.web_client.api_test() + assert resp is not None + + def test_success(self): + app = App(client=self.web_client) + + @app.event("app_mention") + def handle_app_mention_events(say: Say): + say( + text="This is a section block with a button.", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "This is a section block with a button.", + }, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Click Me"}, + "value": "clicked", + "action_id": "button", + }, + } + ], + ) + + @app.action("button") + def handle_button_clicks(body: dict, ack: Ack, respond: Respond): + respond( + text="hey!", + thread_ts=body["message"]["ts"], + response_type="in_channel", + replace_original=False, + ) + ack() + + # app_mention event + request = BoltRequest( + mode="socket_mode", + body={ + "team_id": "T0G9PQBBK", + "api_app_id": "A111", + "event": { + "type": "app_mention", + "text": "<@U111> hey", + "user": "U222", + "ts": "1678252212.229129", + "blocks": [ + { + "type": "rich_text", + "block_id": "BCCO", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "U111"}, + {"type": "text", "text": " hey"}, + ], + } + ], + } + ], + "team": "T0G9PQBBK", + "channel": "C111", + "event_ts": "1678252212.229129", + }, + "type": "event_callback", + "event_id": "Ev04SPP46R6J", + "event_time": 1678252212, + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T0G9PQBBK", + "user_id": "U111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "4-xxx", + }, + ) + response = app.dispatch(request) + assert response.status == 200 + + # block_actions request + request = BoltRequest( + mode="socket_mode", + body={ + "type": "block_actions", + "user": {"id": "U111"}, + "api_app_id": "A111", + "container": { + "type": "message", + "message_ts": "1678252213.679169", + "channel_id": "C111", + "is_ephemeral": False, + }, + "trigger_id": "4916855695380.xxx.yyy", + "team": {"id": "T0G9PQBBK"}, + "enterprise": None, + "is_enterprise_install": False, + "channel": {"id": "C111"}, + "message": { + "bot_id": "B111", + "type": "message", + "text": "This is a section block with a button.", + "user": "U222", + "ts": "1678252213.679169", + "app_id": "A111", + "blocks": [ + { + "type": "section", + "block_id": "8KR", + "text": { + "type": "mrkdwn", + "text": "This is a section block with a button.", + "verbatim": False, + }, + "accessory": { + "type": "button", + "action_id": "button", + "text": {"type": "plain_text", "text": "Click Me"}, + "value": "clicked", + }, + } + ], + "team": "T0G9PQBBK", + }, + "state": {"values": {}}, + "response_url": "http://localhost:8888/webhook", + "actions": [ + { + "action_id": "button", + "block_id": "8KR", + "text": {"type": "plain_text", "text": "Click Me"}, + "value": "clicked", + "type": "button", + "action_ts": "1678252216.469172", + } + ], + }, + ) + response = app.dispatch(request) + assert response.status == 200 diff --git a/tests/scenario_tests_async/test_block_actions_respond.py b/tests/scenario_tests_async/test_block_actions_respond.py new file mode 100644 index 000000000..87e670fe7 --- /dev/null +++ b/tests/scenario_tests_async/test_block_actions_respond.py @@ -0,0 +1,180 @@ +import asyncio +import pytest +from slack_sdk.signature import SignatureVerifier +from slack_sdk.web.async_client import AsyncWebClient + +from slack_bolt.async_app import AsyncRespond, AsyncAck, AsyncSay +from slack_bolt.app.async_app import AsyncApp +from slack_bolt.request.async_request import AsyncBoltRequest +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 + + +class TestAsyncBlockActionsRespond: + signing_secret = "secret" + 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_success(self): + app = AsyncApp(client=self.web_client) + + @app.event("app_mention") + async def handle_app_mention_events(say: AsyncSay): + await say( + text="This is a section block with a button.", + blocks=[ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "This is a section block with a button.", + }, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Click Me"}, + "value": "clicked", + "action_id": "button", + }, + } + ], + ) + + @app.action("button") + async def handle_button_clicks(body: dict, ack: AsyncAck, respond: AsyncRespond): + await respond( + text="hey!", + thread_ts=body["message"]["ts"], + response_type="in_channel", + replace_original=False, + ) + await ack() + + # app_mention event + request = AsyncBoltRequest( + mode="socket_mode", + body={ + "team_id": "T0G9PQBBK", + "api_app_id": "A111", + "event": { + "type": "app_mention", + "text": "<@U111> hey", + "user": "U222", + "ts": "1678252212.229129", + "blocks": [ + { + "type": "rich_text", + "block_id": "BCCO", + "elements": [ + { + "type": "rich_text_section", + "elements": [ + {"type": "user", "user_id": "U111"}, + {"type": "text", "text": " hey"}, + ], + } + ], + } + ], + "team": "T0G9PQBBK", + "channel": "C111", + "event_ts": "1678252212.229129", + }, + "type": "event_callback", + "event_id": "Ev04SPP46R6J", + "event_time": 1678252212, + "authorizations": [ + { + "enterprise_id": None, + "team_id": "T0G9PQBBK", + "user_id": "U111", + "is_bot": True, + "is_enterprise_install": False, + } + ], + "is_ext_shared_channel": False, + "event_context": "4-xxx", + }, + ) + response = await app.async_dispatch(request) + assert response.status == 200 + + # block_actions request + request = AsyncBoltRequest( + mode="socket_mode", + body={ + "type": "block_actions", + "user": {"id": "U111"}, + "api_app_id": "A111", + "container": { + "type": "message", + "message_ts": "1678252213.679169", + "channel_id": "C111", + "is_ephemeral": False, + }, + "trigger_id": "4916855695380.xxx.yyy", + "team": {"id": "T0G9PQBBK"}, + "enterprise": None, + "is_enterprise_install": False, + "channel": {"id": "C111"}, + "message": { + "bot_id": "B111", + "type": "message", + "text": "This is a section block with a button.", + "user": "U222", + "ts": "1678252213.679169", + "app_id": "A111", + "blocks": [ + { + "type": "section", + "block_id": "8KR", + "text": { + "type": "mrkdwn", + "text": "This is a section block with a button.", + "verbatim": False, + }, + "accessory": { + "type": "button", + "action_id": "button", + "text": {"type": "plain_text", "text": "Click Me"}, + "value": "clicked", + }, + } + ], + "team": "T0G9PQBBK", + }, + "state": {"values": {}}, + "response_url": "http://localhost:8888/webhook", + "actions": [ + { + "action_id": "button", + "block_id": "8KR", + "text": {"type": "plain_text", "text": "Click Me"}, + "value": "clicked", + "type": "button", + "action_ts": "1678252216.469172", + } + ], + }, + ) + response = await app.async_dispatch(request) + assert response.status == 200 diff --git a/tests/scenario_tests_async/test_web_client_customization.py b/tests/scenario_tests_async/test_web_client_customization.py index 08c7755f4..8cd6d840e 100644 --- a/tests/scenario_tests_async/test_web_client_customization.py +++ b/tests/scenario_tests_async/test_web_client_customization.py @@ -58,7 +58,6 @@ def build_valid_request(self) -> AsyncBoltRequest: timestamp = str(int(time())) return AsyncBoltRequest(body=raw_body, headers=self.build_headers(timestamp, raw_body)) - @pytest.mark.asyncio async def test_web_client_customization(self): if os.environ.get("BOLT_PYTHON_CODECOV_RUNNING") == "1":